Layer 3: Templates as pure data, weather template, .anvilignore refresh system
Templates are now composed declaratively via template.toml -- no Rust code changes needed to add new templates. The weather station is the first composed template, demonstrating the full pattern. Template engine: - Composed templates declare base, required libraries, and per-board pins - Overlay mechanism replaces base files (app, sketch, tests) cleanly - Generic orchestration: extract base, apply overlay, install libs, assign pins - Template name tracked in .anvil.toml for refresh awareness Weather template (--template weather): - WeatherApp with 2-second polling, C/F conversion, serial output - TMP36 driver: TempSensor interface, Tmp36 impl, Tmp36Mock, Tmp36Sim - Managed example tests in test_weather.cpp (unit + system) - Minimal student starters in test_unit.cpp and test_system.cpp - Per-board pin defaults (A0 for uno, A0 for mega, A0 for nano) .anvilignore system: - Glob pattern matching (*, ?) with comments and backslash normalization - Default patterns protect student tests, app code, sketch, config - anvil refresh --force respects .anvilignore - anvil refresh --force --file <path> overrides ignore for one file - anvil refresh --ignore/--unignore manages patterns from CLI - Missing managed files always recreated even without --force - .anvilignore itself is in NEVER_REFRESH (cannot be overwritten) Refresh rewrite: - Discovers all template-produced files dynamically (no hardcoded list) - Extracts fresh template + libraries into temp dir for byte comparison - Config template field drives which files are managed - Separated missing-file creation from changed-file updates 428 tests passing on Windows MSVC, 0 warnings.
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
use include_dir::{include_dir, Dir};
|
||||
use std::path::Path;
|
||||
use std::fs;
|
||||
use anyhow::{Result, bail, Context};
|
||||
use std::collections::HashMap;
|
||||
use anyhow::{Result, Context};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::version::ANVIL_VERSION;
|
||||
|
||||
// Embedded template directories
|
||||
static BASIC_TEMPLATE: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/basic");
|
||||
static WEATHER_TEMPLATE: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/weather");
|
||||
|
||||
/// Context variables available in .tmpl files via {{VAR}} substitution.
|
||||
pub struct TemplateContext {
|
||||
pub project_name: String,
|
||||
pub anvil_version: String,
|
||||
@@ -15,87 +20,282 @@ pub struct TemplateContext {
|
||||
pub baud: u32,
|
||||
}
|
||||
|
||||
/// Summary info for listing templates.
|
||||
pub struct TemplateInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub is_default: bool,
|
||||
pub libraries: Vec<String>,
|
||||
pub board_capabilities: Vec<String>,
|
||||
}
|
||||
|
||||
/// Metadata extracted from a composed template's template.toml.
|
||||
/// Used by the `new` command to install libraries and assign pins.
|
||||
pub struct ComposedMeta {
|
||||
pub base: String,
|
||||
pub libraries: Vec<String>,
|
||||
pub board_capabilities: Vec<String>,
|
||||
pub pins: HashMap<String, Vec<PinDefault>>,
|
||||
}
|
||||
|
||||
/// A default pin assignment from a template.
|
||||
pub struct PinDefault {
|
||||
pub name: String,
|
||||
pub pin: String,
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
impl ComposedMeta {
|
||||
/// Get pin defaults for a given board, falling back to "default".
|
||||
pub fn pins_for_board(&self, board: &str) -> Vec<&PinDefault> {
|
||||
if let Some(pins) = self.pins.get(board) {
|
||||
return pins.iter().collect();
|
||||
}
|
||||
if let Some(pins) = self.pins.get("default") {
|
||||
return pins.iter().collect();
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// template.toml parsing (serde)
|
||||
// =========================================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TemplateToml {
|
||||
template: TemplateHeader,
|
||||
requires: Option<TemplateRequires>,
|
||||
pins: Option<HashMap<String, HashMap<String, PinDef>>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TemplateHeader {
|
||||
#[allow(dead_code)]
|
||||
name: String,
|
||||
base: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TemplateRequires {
|
||||
libraries: Option<Vec<String>>,
|
||||
board_capabilities: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PinDef {
|
||||
pin: String,
|
||||
mode: String,
|
||||
}
|
||||
|
||||
/// Parse template.toml from an embedded directory.
|
||||
fn parse_template_toml(dir: &Dir<'_>) -> Option<TemplateToml> {
|
||||
let file = dir.files().find(|f| {
|
||||
f.path()
|
||||
.file_name()
|
||||
.map(|n| n == "template.toml")
|
||||
.unwrap_or(false)
|
||||
})?;
|
||||
let content = std::str::from_utf8(file.contents()).ok()?;
|
||||
toml::from_str(content).ok()
|
||||
}
|
||||
|
||||
/// Look up the embedded Dir for a template name.
|
||||
fn template_dir(name: &str) -> Option<&'static Dir<'static>> {
|
||||
match name {
|
||||
"basic" => Some(&BASIC_TEMPLATE),
|
||||
"weather" => Some(&WEATHER_TEMPLATE),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// All composed templates (everything except "basic").
|
||||
fn composed_template_entries() -> Vec<(&'static str, &'static Dir<'static>)> {
|
||||
vec![("weather", &WEATHER_TEMPLATE)]
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TemplateManager -- public API
|
||||
// =========================================================================
|
||||
|
||||
pub struct TemplateManager;
|
||||
|
||||
impl TemplateManager {
|
||||
/// Check if a template name is known.
|
||||
pub fn template_exists(name: &str) -> bool {
|
||||
matches!(name, "basic")
|
||||
template_dir(name).is_some()
|
||||
}
|
||||
|
||||
/// List all available templates with metadata.
|
||||
pub fn list_templates() -> Vec<TemplateInfo> {
|
||||
vec![
|
||||
TemplateInfo {
|
||||
name: "basic".to_string(),
|
||||
description: "Arduino project with HAL abstraction, mocks, and test infrastructure".to_string(),
|
||||
is_default: true,
|
||||
},
|
||||
]
|
||||
let mut templates = vec![TemplateInfo {
|
||||
name: "basic".to_string(),
|
||||
description: "Arduino project with HAL abstraction, mocks, \
|
||||
and test infrastructure"
|
||||
.to_string(),
|
||||
is_default: true,
|
||||
libraries: vec![],
|
||||
board_capabilities: vec![],
|
||||
}];
|
||||
|
||||
// Composed templates: parse their template.toml
|
||||
for (name, dir) in composed_template_entries() {
|
||||
if let Some(toml) = parse_template_toml(dir) {
|
||||
templates.push(TemplateInfo {
|
||||
name: name.to_string(),
|
||||
description: toml.template.description,
|
||||
is_default: false,
|
||||
libraries: toml
|
||||
.requires
|
||||
.as_ref()
|
||||
.and_then(|r| r.libraries.clone())
|
||||
.unwrap_or_default(),
|
||||
board_capabilities: toml
|
||||
.requires
|
||||
.as_ref()
|
||||
.and_then(|r| r.board_capabilities.clone())
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
templates
|
||||
}
|
||||
|
||||
/// Extract a template into the output directory, applying variable
|
||||
/// substitution and filename transformations.
|
||||
/// Extract a template into a project directory.
|
||||
///
|
||||
/// For base templates (basic): extracts files directly.
|
||||
/// For composed templates (weather): extracts base first, then overlays.
|
||||
pub fn extract(
|
||||
template_name: &str,
|
||||
output_dir: &Path,
|
||||
context: &TemplateContext,
|
||||
) -> Result<usize> {
|
||||
let template_dir = match template_name {
|
||||
"basic" => &BASIC_TEMPLATE,
|
||||
_ => bail!("Unknown template: {}", template_name),
|
||||
};
|
||||
let dir = template_dir(template_name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Unknown template: {}", template_name))?;
|
||||
|
||||
let count = extract_dir(template_dir, output_dir, "", context)?;
|
||||
Ok(count)
|
||||
// Check if this is a composed template
|
||||
if let Some(toml) = parse_template_toml(dir) {
|
||||
// Extract base first
|
||||
let base_dir = template_dir(&toml.template.base).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Template '{}' requires unknown base '{}'",
|
||||
template_name,
|
||||
toml.template.base
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut count =
|
||||
extract_dir_filtered(base_dir, output_dir, context, &[])?;
|
||||
|
||||
// Overlay template-specific files (skip template.toml)
|
||||
count += extract_dir_filtered(
|
||||
dir,
|
||||
output_dir,
|
||||
context,
|
||||
&["template.toml"],
|
||||
)?;
|
||||
|
||||
Ok(count)
|
||||
} else {
|
||||
// Base template -- extract directly
|
||||
extract_dir_filtered(dir, output_dir, context, &[])
|
||||
}
|
||||
}
|
||||
|
||||
/// Get composed metadata for a template (libraries, pins, capabilities).
|
||||
/// Returns None for base templates like "basic".
|
||||
pub fn composed_meta(template_name: &str) -> Option<ComposedMeta> {
|
||||
let dir = template_dir(template_name)?;
|
||||
let toml = parse_template_toml(dir)?;
|
||||
|
||||
let libraries = toml
|
||||
.requires
|
||||
.as_ref()
|
||||
.and_then(|r| r.libraries.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let board_capabilities = toml
|
||||
.requires
|
||||
.as_ref()
|
||||
.and_then(|r| r.board_capabilities.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut pins: HashMap<String, Vec<PinDefault>> = HashMap::new();
|
||||
if let Some(pin_map) = toml.pins {
|
||||
for (board, assignments) in pin_map {
|
||||
let mut defs: Vec<PinDefault> = assignments
|
||||
.into_iter()
|
||||
.map(|(name, def)| PinDefault {
|
||||
name,
|
||||
pin: def.pin,
|
||||
mode: def.mode,
|
||||
})
|
||||
.collect();
|
||||
// Sort for deterministic output
|
||||
defs.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
pins.insert(board, defs);
|
||||
}
|
||||
}
|
||||
|
||||
Some(ComposedMeta {
|
||||
base: toml.template.base,
|
||||
libraries,
|
||||
board_capabilities,
|
||||
pins,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TemplateInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub is_default: bool,
|
||||
}
|
||||
// =========================================================================
|
||||
// File extraction
|
||||
// =========================================================================
|
||||
|
||||
/// Recursively extract a directory from the embedded template.
|
||||
fn extract_dir(
|
||||
/// Recursively extract files from an embedded directory, applying variable
|
||||
/// substitution to .tmpl files and path transformations.
|
||||
fn extract_dir_filtered(
|
||||
source: &Dir<'_>,
|
||||
output_base: &Path,
|
||||
relative_prefix: &str,
|
||||
context: &TemplateContext,
|
||||
skip_filenames: &[&str],
|
||||
) -> Result<usize> {
|
||||
let mut count = 0;
|
||||
|
||||
for file in source.files() {
|
||||
let file_path = file.path();
|
||||
let file_name = file_path.to_string_lossy().to_string();
|
||||
let file_name = file_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Build the output path with transformations
|
||||
let output_rel = transform_path(&file_name, &context.project_name);
|
||||
let output_path = output_base.join(&output_rel);
|
||||
|
||||
// Create parent directories
|
||||
if let Some(parent) = output_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.context(format!("Failed to create directory: {}", parent.display()))?;
|
||||
// Skip files in the skip list
|
||||
if skip_filenames.iter().any(|&s| s == file_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let full_path = file_path.to_string_lossy().to_string();
|
||||
let output_rel = transform_path(&full_path, &context.project_name);
|
||||
let output_path = output_base.join(&output_rel);
|
||||
|
||||
if let Some(parent) = output_path.parent() {
|
||||
fs::create_dir_all(parent).context(format!(
|
||||
"Failed to create directory: {}",
|
||||
parent.display()
|
||||
))?;
|
||||
}
|
||||
|
||||
// Read file contents
|
||||
let contents = file.contents();
|
||||
|
||||
// Check if this is a template file (.tmpl suffix)
|
||||
if output_rel.ends_with(".tmpl") {
|
||||
// Variable substitution
|
||||
let text = std::str::from_utf8(contents)
|
||||
.context("Template file must be UTF-8")?;
|
||||
let processed = substitute_variables(text, context);
|
||||
|
||||
// Remove .tmpl extension
|
||||
let final_path_str = output_rel.trim_end_matches(".tmpl");
|
||||
let final_path = output_base.join(final_path_str);
|
||||
|
||||
if let Some(parent) = final_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
fs::write(&final_path, processed)?;
|
||||
count += 1;
|
||||
} else {
|
||||
@@ -104,9 +304,9 @@ fn extract_dir(
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into subdirectories
|
||||
for dir in source.dirs() {
|
||||
count += extract_dir(dir, output_base, relative_prefix, context)?;
|
||||
count +=
|
||||
extract_dir_filtered(dir, output_base, context, skip_filenames)?;
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
@@ -117,12 +317,8 @@ fn extract_dir(
|
||||
/// - `__name__` -> project name
|
||||
fn transform_path(path: &str, project_name: &str) -> String {
|
||||
let mut result = path.to_string();
|
||||
|
||||
// Replace __name__ with project name in all path components
|
||||
result = result.replace("__name__", project_name);
|
||||
|
||||
// Handle _dot_ prefix for hidden files.
|
||||
// Split into components and transform each.
|
||||
let parts: Vec<&str> = result.split('/').collect();
|
||||
let transformed: Vec<String> = parts
|
||||
.iter()
|
||||
@@ -148,6 +344,10 @@ fn substitute_variables(text: &str, context: &TemplateContext) -> String {
|
||||
.replace("{{BAUD}}", &context.baud.to_string())
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Tests
|
||||
// =========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -182,20 +382,53 @@ mod tests {
|
||||
fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(),
|
||||
baud: 9600,
|
||||
};
|
||||
let input = "Name: {{PROJECT_NAME}}, Board: {{BOARD_NAME}}, FQBN: {{FQBN}}, Baud: {{BAUD}}";
|
||||
let input = "Name: {{PROJECT_NAME}}, Board: {{BOARD_NAME}}, \
|
||||
FQBN: {{FQBN}}, Baud: {{BAUD}}";
|
||||
let output = substitute_variables(input, &ctx);
|
||||
assert_eq!(
|
||||
output,
|
||||
"Name: my_project, Board: mega, FQBN: arduino:avr:mega:cpu=atmega2560, Baud: 9600"
|
||||
"Name: my_project, Board: mega, \
|
||||
FQBN: arduino:avr:mega:cpu=atmega2560, Baud: 9600"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_exists() {
|
||||
assert!(TemplateManager::template_exists("basic"));
|
||||
assert!(TemplateManager::template_exists("weather"));
|
||||
assert!(!TemplateManager::template_exists("nonexistent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_templates_includes_both() {
|
||||
let templates = TemplateManager::list_templates();
|
||||
assert!(templates.iter().any(|t| t.name == "basic"));
|
||||
assert!(templates.iter().any(|t| t.name == "weather"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_is_default() {
|
||||
let templates = TemplateManager::list_templates();
|
||||
let basic = templates.iter().find(|t| t.name == "basic").unwrap();
|
||||
assert!(basic.is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weather_requires_tmp36() {
|
||||
let templates = TemplateManager::list_templates();
|
||||
let weather = templates.iter().find(|t| t.name == "weather").unwrap();
|
||||
assert!(weather.libraries.contains(&"tmp36".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weather_requires_analog() {
|
||||
let templates = TemplateManager::list_templates();
|
||||
let weather = templates.iter().find(|t| t.name == "weather").unwrap();
|
||||
assert!(
|
||||
weather.board_capabilities.contains(&"analog".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_basic_template() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -207,26 +440,15 @@ mod tests {
|
||||
baud: 115200,
|
||||
};
|
||||
|
||||
let count = TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
||||
let count =
|
||||
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
||||
assert!(count > 0, "Should extract at least one file");
|
||||
|
||||
// Verify key files exist
|
||||
assert!(tmp.path().join(".anvil.toml").exists());
|
||||
assert!(
|
||||
tmp.path().join(".anvil.toml").exists(),
|
||||
".anvil.toml should be created"
|
||||
);
|
||||
assert!(
|
||||
tmp.path().join("test_proj").join("test_proj.ino").exists(),
|
||||
"Sketch .ino should be created"
|
||||
);
|
||||
assert!(
|
||||
tmp.path().join("lib").join("hal").join("hal.h").exists(),
|
||||
"HAL header should be created"
|
||||
);
|
||||
assert!(
|
||||
tmp.path().join(".gitignore").exists(),
|
||||
".gitignore should be created"
|
||||
tmp.path().join("test_proj").join("test_proj.ino").exists()
|
||||
);
|
||||
assert!(tmp.path().join("lib").join("hal").join("hal.h").exists());
|
||||
assert!(tmp.path().join(".gitignore").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -241,12 +463,105 @@ mod tests {
|
||||
};
|
||||
|
||||
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
||||
let config_content =
|
||||
fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap();
|
||||
assert!(config_content.contains("my_sensor"));
|
||||
}
|
||||
|
||||
// Read the generated .anvil.toml and check for project name
|
||||
let config_content = fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap();
|
||||
#[test]
|
||||
fn test_weather_composed_meta() {
|
||||
let meta = TemplateManager::composed_meta("weather").unwrap();
|
||||
assert_eq!(meta.base, "basic");
|
||||
assert!(meta.libraries.contains(&"tmp36".to_string()));
|
||||
assert!(!meta.pins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weather_pins_for_board_uno() {
|
||||
let meta = TemplateManager::composed_meta("weather").unwrap();
|
||||
let pins = meta.pins_for_board("uno");
|
||||
assert!(!pins.is_empty());
|
||||
assert!(pins.iter().any(|p| p.name == "tmp36_data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weather_pins_for_board_fallback() {
|
||||
let meta = TemplateManager::composed_meta("weather").unwrap();
|
||||
// Board "micro" is not in the template, falls back to "default"
|
||||
let pins = meta.pins_for_board("micro");
|
||||
assert!(!pins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_has_no_composed_meta() {
|
||||
assert!(TemplateManager::composed_meta("basic").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_weather_overlays_app() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = TemplateContext {
|
||||
project_name: "wx".to_string(),
|
||||
anvil_version: "1.0.0".to_string(),
|
||||
board_name: "uno".to_string(),
|
||||
fqbn: "arduino:avr:uno".to_string(),
|
||||
baud: 115200,
|
||||
};
|
||||
|
||||
TemplateManager::extract("weather", tmp.path(), &ctx).unwrap();
|
||||
|
||||
// Should have basic scaffold
|
||||
assert!(tmp.path().join(".anvil.toml").exists());
|
||||
assert!(tmp.path().join("build.sh").exists());
|
||||
assert!(tmp.path().join("lib").join("hal").join("hal.h").exists());
|
||||
|
||||
// Should have weather-specific app
|
||||
let app_path = tmp.path().join("lib").join("app").join("wx_app.h");
|
||||
assert!(app_path.exists(), "Weather app.h should exist");
|
||||
let app_content = fs::read_to_string(&app_path).unwrap();
|
||||
assert!(
|
||||
config_content.contains("my_sensor"),
|
||||
".anvil.toml should contain project name"
|
||||
app_content.contains("WeatherApp"),
|
||||
"Should contain WeatherApp class"
|
||||
);
|
||||
assert!(
|
||||
app_content.contains("TempSensor"),
|
||||
"Should reference TempSensor"
|
||||
);
|
||||
|
||||
// Should have weather-specific sketch
|
||||
let ino_path = tmp.path().join("wx").join("wx.ino");
|
||||
assert!(ino_path.exists());
|
||||
let ino_content = fs::read_to_string(&ino_path).unwrap();
|
||||
assert!(ino_content.contains("Tmp36Analog"));
|
||||
assert!(ino_content.contains("WeatherApp"));
|
||||
|
||||
// Should have weather-specific tests
|
||||
let unit_test = tmp.path().join("test").join("test_unit.cpp");
|
||||
assert!(unit_test.exists());
|
||||
let unit_content = fs::read_to_string(&unit_test).unwrap();
|
||||
assert!(unit_content.contains("Tmp36Mock"));
|
||||
|
||||
let sys_test = tmp.path().join("test").join("test_system.cpp");
|
||||
assert!(sys_test.exists());
|
||||
let sys_content = fs::read_to_string(&sys_test).unwrap();
|
||||
assert!(sys_content.contains("Tmp36Sim"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weather_no_template_toml_in_output() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = TemplateContext {
|
||||
project_name: "wx".to_string(),
|
||||
anvil_version: "1.0.0".to_string(),
|
||||
board_name: "uno".to_string(),
|
||||
fqbn: "arduino:avr:uno".to_string(),
|
||||
baud: 115200,
|
||||
};
|
||||
|
||||
TemplateManager::extract("weather", tmp.path(), &ctx).unwrap();
|
||||
assert!(
|
||||
!tmp.path().join("template.toml").exists(),
|
||||
"template.toml should NOT appear in output"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user