use std::fs::{self, OpenOptions};
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use url::Url;
use crate::error::SpecmanError;
use crate::scratchpad::{ScratchPadProfile, ScratchPadProfileKind};
use crate::template::{
TemplateDescriptor, TemplateLocator, TemplateProvenance, TemplateScenario, TemplateTier,
};
use crate::workspace::{WorkspacePaths, workspace_relative_path};
const EMBEDDED_SPEC: &str = include_str!("templates/spec/spec.md");
const EMBEDDED_IMPL: &str = include_str!("templates/impl/impl.md");
const EMBEDDED_SCRATCH_REF: &str = include_str!("templates/scratch/ref.md");
const EMBEDDED_SCRATCH_FEAT: &str = include_str!("templates/scratch/feat.md");
const EMBEDDED_SCRATCH_FIX: &str = include_str!("templates/scratch/fix.md");
const EMBEDDED_SCRATCH_REVISION: &str = include_str!("templates/scratch/revision.md");
pub struct TemplateCatalog {
workspace: WorkspacePaths,
}
#[derive(Clone, Debug)]
pub struct ResolvedTemplate {
pub descriptor: TemplateDescriptor,
pub provenance: TemplateProvenance,
}
#[derive(Default)]
struct ResolvedFromPathOverrides {
pointer: Option<String>,
locator_override: Option<String>,
cache_override: Option<String>,
last_modified: Option<String>,
}
impl TemplateCatalog {
pub fn new(workspace: WorkspacePaths) -> Self {
Self { workspace }
}
pub fn resolve(&self, scenario: TemplateScenario) -> Result<ResolvedTemplate, SpecmanError> {
validate_scenario(&scenario)?;
if let Some(resolved) = self.try_workspace_override(&scenario)? {
return Ok(resolved);
}
if let Some(resolved) = self.try_pointer(&scenario)? {
return Ok(resolved);
}
self.embedded_default(&scenario)
}
pub fn set_pointer(
&self,
scenario: TemplateScenario,
locator: impl AsRef<str>,
) -> Result<ResolvedTemplate, SpecmanError> {
validate_scenario(&scenario)?;
let pointer_name = pointer_name(&scenario);
let templates_dir = self.templates_dir();
let lock = PointerLock::acquire(&templates_dir, pointer_name)?;
let destination = self.normalize_pointer_locator(locator.as_ref())?;
if let PointerDestination::Remote(url) = &destination {
let cache = TemplateCache::new(&self.workspace);
cache.fetch_url(url)?;
}
self.write_pointer_file(pointer_name, destination.contents())?;
drop(lock);
self.resolve(scenario)
}
pub fn remove_pointer(
&self,
scenario: TemplateScenario,
) -> Result<ResolvedTemplate, SpecmanError> {
validate_scenario(&scenario)?;
let pointer_name = pointer_name(&scenario);
let templates_dir = self.templates_dir();
let lock = PointerLock::acquire(&templates_dir, pointer_name)?;
let pointer_path = templates_dir.join(pointer_name);
if !pointer_path.is_file() {
return Err(SpecmanError::Template(format!(
"pointer {} does not exist under {}",
pointer_name,
pointer_path.display()
)));
}
let raw = fs::read_to_string(&pointer_path).map_err(|err| {
SpecmanError::Template(format!(
"failed to read pointer {}: {}",
pointer_path.display(),
err
))
})?;
fs::remove_file(&pointer_path).map_err(|err| {
SpecmanError::Template(format!(
"failed to remove pointer {}: {}",
pointer_path.display(),
err
))
})?;
let trimmed = raw.trim();
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
let url = Url::parse(trimmed).map_err(|err| {
SpecmanError::Template(format!(
"pointer {pointer_name} referenced invalid URL {trimmed}: {err}"
))
})?;
let cache = TemplateCache::new(&self.workspace);
cache.invalidate_url(&url)?;
}
self.refresh_embedded_cache(&scenario)?;
drop(lock);
self.resolve(scenario)
}
pub fn scratch_profile(
&self,
kind: ScratchPadProfileKind,
) -> Result<ScratchPadProfile, SpecmanError> {
let scenario = TemplateScenario::WorkType(kind.slug().to_string());
let resolved = self.resolve(scenario)?;
Ok(ScratchPadProfile {
kind,
name: String::new(),
template: resolved.descriptor,
provenance: Some(resolved.provenance),
configuration: Default::default(),
})
}
fn templates_dir(&self) -> PathBuf {
self.workspace.dot_specman().join("templates")
}
fn normalize_pointer_locator(&self, raw: &str) -> Result<PointerDestination, SpecmanError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(SpecmanError::Template(
"pointer locator must not be empty".to_string(),
));
}
if trimmed.starts_with("http://") {
return Err(SpecmanError::Template(
"remote template pointers must use https".to_string(),
));
}
if trimmed.starts_with("https://") {
let url = Url::parse(trimmed).map_err(|err| {
SpecmanError::Template(format!(
"pointer locator {trimmed} is not a valid URL: {err}"
))
})?;
return Ok(PointerDestination::Remote(url));
}
let candidate = PathBuf::from(trimmed);
let resolved = if candidate.is_absolute() {
candidate
} else {
self.workspace.root().join(&candidate)
};
if !resolved.starts_with(self.workspace.root()) {
return Err(SpecmanError::Template(format!(
"pointer locator escapes the workspace: {}",
resolved.display()
)));
}
if !resolved.is_file() {
return Err(SpecmanError::Template(format!(
"pointer locator does not exist: {}",
resolved.display()
)));
}
Ok(PointerDestination::FilePath(workspace_relative(
self.workspace.root(),
&resolved,
)))
}
fn write_pointer_file(&self, pointer: &str, contents: String) -> Result<(), SpecmanError> {
let dir = self.templates_dir();
fs::create_dir_all(&dir).map_err(|err| {
SpecmanError::Template(format!(
"failed to ensure template directory {}: {}",
dir.display(),
err
))
})?;
let pointer_path = dir.join(pointer);
let tmp_path = pointer_path.with_extension("tmp");
fs::write(&tmp_path, format!("{contents}\n")).map_err(|err| {
let _ = fs::remove_file(&tmp_path);
SpecmanError::Template(format!(
"failed to write temporary pointer {}: {}",
tmp_path.display(),
err
))
})?;
if pointer_path.is_file() {
fs::remove_file(&pointer_path).map_err(|err| {
SpecmanError::Template(format!(
"failed to remove previous pointer {}: {}",
pointer_path.display(),
err
))
})?;
}
fs::rename(&tmp_path, &pointer_path).map_err(|err| {
let _ = fs::remove_file(&tmp_path);
SpecmanError::Template(format!(
"failed to publish pointer {}: {}",
pointer_path.display(),
err
))
})
}
fn refresh_embedded_cache(&self, scenario: &TemplateScenario) -> Result<(), SpecmanError> {
let (key, body) = embedded_assets(scenario)?;
let cache = TemplateCache::new(&self.workspace);
cache.write_embedded(key, body).map(|_| ())
}
fn try_workspace_override(
&self,
scenario: &TemplateScenario,
) -> Result<Option<ResolvedTemplate>, SpecmanError> {
for candidate in self.override_candidates(scenario) {
if candidate.is_file() {
return Ok(Some(self.resolved_from_path(
scenario,
candidate,
TemplateTier::WorkspaceOverride,
ResolvedFromPathOverrides::default(),
)));
}
}
Ok(None)
}
fn try_pointer(
&self,
scenario: &TemplateScenario,
) -> Result<Option<ResolvedTemplate>, SpecmanError> {
let pointer_name = pointer_name(scenario);
let pointer_path = self.templates_dir().join(pointer_name);
if !pointer_path.is_file() {
return Ok(None);
}
let contents = fs::read_to_string(&pointer_path).map_err(|err| {
SpecmanError::Template(format!(
"failed to read template pointer {}: {err}",
pointer_path.display()
))
})?;
let trimmed = contents.trim();
if trimmed.is_empty() {
return Err(SpecmanError::Template(format!(
"template pointer {} has no content",
pointer_path.display()
)));
}
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
let url = Url::parse(trimmed).map_err(|err| {
SpecmanError::Template(format!("invalid template pointer URL {trimmed}: {err}"))
})?;
let cache = TemplateCache::new(&self.workspace);
match cache.fetch_url(&url) {
Ok(hit) => {
let cache_path = workspace_relative(self.workspace.root(), &hit.path);
return Ok(Some(self.resolved_from_path(
scenario,
hit.path,
TemplateTier::PointerUrl,
ResolvedFromPathOverrides {
pointer: Some(pointer_name.to_string()),
locator_override: Some(url.to_string()),
cache_override: Some(cache_path),
last_modified: hit.last_modified,
},
)));
}
Err(_err) => {
return Ok(None);
}
}
}
let file_path = self.resolve_pointer_path(trimmed, pointer_name)?;
Ok(Some(self.resolved_from_path(
scenario,
file_path,
TemplateTier::PointerFile,
ResolvedFromPathOverrides {
pointer: Some(pointer_name.to_string()),
..ResolvedFromPathOverrides::default()
},
)))
}
fn embedded_default(
&self,
scenario: &TemplateScenario,
) -> Result<ResolvedTemplate, SpecmanError> {
let (key, body) = embedded_assets(scenario)?;
let cache = TemplateCache::new(&self.workspace);
let path = cache.write_embedded(key, body)?;
let cache_path = workspace_relative(self.workspace.root(), &path);
Ok(self.resolved_from_path(
scenario,
path,
TemplateTier::EmbeddedDefault,
ResolvedFromPathOverrides {
locator_override: Some(format!("embedded://{key}")),
cache_override: Some(cache_path),
..ResolvedFromPathOverrides::default()
},
))
}
fn override_candidates(&self, scenario: &TemplateScenario) -> Vec<PathBuf> {
let base = self.templates_dir();
match scenario {
TemplateScenario::Specification => vec![base.join("spec.md")],
TemplateScenario::Implementation => vec![base.join("impl.md")],
TemplateScenario::WorkType(kind) => {
let slug = sanitize_key(kind);
vec![
base.join("scratch").join(format!("{slug}.md")),
base.join(format!("scratch-{slug}.md")),
]
}
TemplateScenario::ScratchPad => Vec::new(),
}
}
fn resolve_pointer_path(&self, raw: &str, pointer_name: &str) -> Result<PathBuf, SpecmanError> {
let candidate = PathBuf::from(raw);
let resolved = if candidate.is_absolute() {
candidate
} else {
self.workspace.root().join(candidate)
};
if !resolved.starts_with(self.workspace.root()) {
return Err(SpecmanError::Template(format!(
"pointer {} resolved outside the workspace: {}",
pointer_name,
resolved.display()
)));
}
if !resolved.is_file() {
return Err(SpecmanError::Template(format!(
"pointer {} references missing file: {}",
pointer_name,
resolved.display()
)));
}
Ok(resolved)
}
fn resolved_from_path(
&self,
scenario: &TemplateScenario,
path: PathBuf,
tier: TemplateTier,
overrides: ResolvedFromPathOverrides,
) -> ResolvedTemplate {
let ResolvedFromPathOverrides {
pointer,
locator_override,
cache_override,
last_modified,
} = overrides;
let locator = TemplateLocator::FilePath(path.clone());
let provenance = TemplateProvenance {
tier,
locator: locator_override
.unwrap_or_else(|| workspace_relative(self.workspace.root(), &path)),
pointer,
cache_path: cache_override,
last_modified,
};
ResolvedTemplate {
descriptor: TemplateDescriptor {
locator,
scenario: scenario.clone(),
required_tokens: Vec::new(),
},
provenance,
}
}
}
fn pointer_name(scenario: &TemplateScenario) -> &'static str {
match scenario {
TemplateScenario::Specification => "SPEC",
TemplateScenario::Implementation => "IMPL",
TemplateScenario::ScratchPad | TemplateScenario::WorkType(_) => "SCRATCH",
}
}
fn validate_scenario(scenario: &TemplateScenario) -> Result<(), SpecmanError> {
match scenario {
TemplateScenario::Specification | TemplateScenario::Implementation => Ok(()),
TemplateScenario::ScratchPad => Err(SpecmanError::Template(
"scratch pad templates require an explicit work type".to_string(),
)),
TemplateScenario::WorkType(kind) => normalize_work_type_slug(kind).map(|_| ()),
}
}
fn normalize_work_type_slug(kind: &str) -> Result<String, SpecmanError> {
let slug = sanitize_key(kind);
match slug.as_str() {
"ref" | "feat" | "fix" | "revision" => Ok(slug),
_ => Err(SpecmanError::UnknownWorkType(kind.to_string())),
}
}
fn embedded_assets(
scenario: &TemplateScenario,
) -> Result<(&'static str, &'static str), SpecmanError> {
match scenario {
TemplateScenario::Specification => Ok(("spec", EMBEDDED_SPEC)),
TemplateScenario::Implementation => Ok(("impl", EMBEDDED_IMPL)),
TemplateScenario::ScratchPad => Err(SpecmanError::Template(
"scratch pad templates require an explicit work type".to_string(),
)),
TemplateScenario::WorkType(kind) => {
let slug = normalize_work_type_slug(kind)?;
match slug.as_str() {
"ref" => Ok(("scratch-ref", EMBEDDED_SCRATCH_REF)),
"feat" => Ok(("scratch-feat", EMBEDDED_SCRATCH_FEAT)),
"fix" => Ok(("scratch-fix", EMBEDDED_SCRATCH_FIX)),
"revision" => Ok(("scratch-revision", EMBEDDED_SCRATCH_REVISION)),
_ => Err(SpecmanError::UnknownWorkType(kind.to_string())),
}
}
}
}
fn sanitize_key(raw: &str) -> String {
raw.chars()
.filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-' || *ch == '_')
.collect::<String>()
.to_lowercase()
}
fn workspace_relative(root: &Path, path: &Path) -> String {
workspace_relative_path(root, path)
.unwrap_or_else(|| path.to_string_lossy().to_string())
}
struct TemplateCache {
root: PathBuf,
}
impl TemplateCache {
fn new(workspace: &WorkspacePaths) -> Self {
Self {
root: workspace.dot_specman().join("cache").join("templates"),
}
}
fn ensure_root(&self) -> Result<(), SpecmanError> {
fs::create_dir_all(&self.root).map_err(SpecmanError::from)
}
fn write_embedded(&self, key: &str, contents: &str) -> Result<PathBuf, SpecmanError> {
self.ensure_root()?;
let path = self.root.join(format!("embedded-{key}.md"));
fs::write(&path, contents)?;
Ok(path)
}
fn fetch_url(&self, url: &Url) -> Result<CacheHit, SpecmanError> {
self.ensure_root()?;
let key = hash_url(url);
let path = self.root.join(format!("url-{key}.md"));
let meta_path = self.root.join(format!("url-{key}.json"));
match ureq::get(url.as_str()).call() {
Ok(response) => {
if response.status() >= 400 {
return Err(SpecmanError::Template(format!(
"failed to download template {}; status {}",
url,
response.status()
)));
}
let last_modified = response
.header("Last-Modified")
.map(|value| value.to_string());
let body = response
.into_string()
.map_err(|err| SpecmanError::Template(err.to_string()))?;
fs::write(&path, body)?;
let metadata = TemplateCacheMetadata {
locator: url.to_string(),
last_modified: last_modified.clone(),
};
fs::write(&meta_path, serde_json::to_string_pretty(&metadata)?)?;
Ok(CacheHit {
path,
last_modified,
})
}
Err(err) => {
if path.is_file() {
let metadata = read_metadata(&meta_path)?;
return Ok(CacheHit {
path,
last_modified: metadata.and_then(|m| m.last_modified),
});
}
Err(SpecmanError::Template(format!(
"failed to download template {url}: {err}"
)))
}
}
}
fn invalidate_url(&self, url: &Url) -> Result<(), SpecmanError> {
if !self.root.exists() {
return Ok(());
}
let key = hash_url(url);
let path = self.root.join(format!("url-{key}.md"));
let meta_path = self.root.join(format!("url-{key}.json"));
if path.is_file() {
fs::remove_file(&path)?;
}
if meta_path.is_file() {
fs::remove_file(&meta_path)?;
}
Ok(())
}
}
#[derive(Serialize, Deserialize)]
struct TemplateCacheMetadata {
locator: String,
#[serde(skip_serializing_if = "Option::is_none")]
last_modified: Option<String>,
}
struct CacheHit {
path: PathBuf,
last_modified: Option<String>,
}
fn read_metadata(path: &Path) -> Result<Option<TemplateCacheMetadata>, SpecmanError> {
if !path.is_file() {
return Ok(None);
}
let content = fs::read_to_string(path)?;
let metadata = serde_json::from_str(&content).map_err(|err| {
SpecmanError::Serialization(format!("invalid template cache metadata: {err}"))
})?;
Ok(Some(metadata))
}
fn hash_url(url: &Url) -> String {
let mut hasher = Sha256::new();
hasher.update(url.as_str().as_bytes());
let digest = hasher.finalize();
hex::encode(digest)
}
enum PointerDestination {
Remote(Url),
FilePath(String),
}
impl PointerDestination {
fn contents(&self) -> String {
match self {
PointerDestination::Remote(url) => url.as_str().to_string(),
PointerDestination::FilePath(path) => path.clone(),
}
}
}
struct PointerLock {
path: PathBuf,
}
impl PointerLock {
fn acquire(dir: &Path, pointer: &str) -> Result<Self, SpecmanError> {
fs::create_dir_all(dir).map_err(|err| {
SpecmanError::Template(format!(
"failed to prepare template directory {}: {}",
dir.display(),
err
))
})?;
let lock_path = dir.join(format!(".lock-{pointer}"));
let start = Instant::now();
loop {
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(_) => return Ok(Self { path: lock_path }),
Err(err) if err.kind() == ErrorKind::AlreadyExists => {
if start.elapsed() >= Duration::from_secs(5) {
return Err(SpecmanError::Template(format!(
"timed out acquiring pointer lock {}",
lock_path.display()
)));
}
thread::sleep(Duration::from_millis(50));
}
Err(err) => {
return Err(SpecmanError::Template(format!(
"failed to create pointer lock {}: {}",
lock_path.display(),
err
)));
}
}
}
}
}
impl Drop for PointerLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workspace::WorkspacePaths;
#[test]
fn sets_pointer_to_workspace_file() {
let (_tempdir, workspace) = workspace_fixture();
let catalog = TemplateCatalog::new(workspace.clone());
let custom_path = workspace.root().join("custom-spec.md");
fs::write(&custom_path, "# spec template").unwrap();
let result = catalog
.set_pointer(TemplateScenario::Specification, "custom-spec.md")
.expect("pointer set should succeed");
assert!(matches!(result.provenance.tier, TemplateTier::PointerFile));
let pointer_contents = fs::read_to_string(catalog.templates_dir().join("SPEC"))
.expect("pointer file readable");
assert_eq!(pointer_contents.trim(), "custom-spec.md");
assert!(
result
.descriptor
.locator
.matches_path(custom_path.as_path())
);
assert!(!catalog.templates_dir().join(".lock-SPEC").exists());
}
#[test]
fn removing_pointer_rewrites_embedded_cache() {
let (_tempdir, workspace) = workspace_fixture();
let catalog = TemplateCatalog::new(workspace.clone());
let custom_path = workspace.root().join("custom-impl.md");
fs::write(&custom_path, "# impl template").unwrap();
catalog
.set_pointer(TemplateScenario::Implementation, "custom-impl.md")
.expect("pointer set");
let embedded_path = workspace
.dot_specman()
.join("cache/templates/embedded-impl.md");
if embedded_path.is_file() {
fs::remove_file(&embedded_path).unwrap();
}
let result = catalog
.remove_pointer(TemplateScenario::Implementation)
.expect("pointer removal succeeds");
assert!(matches!(
result.provenance.tier,
TemplateTier::EmbeddedDefault
));
assert!(embedded_path.is_file());
assert_eq!(fs::read_to_string(&embedded_path).unwrap(), EMBEDDED_IMPL);
assert!(!catalog.templates_dir().join("IMPL").exists());
}
#[test]
fn set_pointer_rejects_http_pointers() {
let (_tempdir, workspace) = workspace_fixture();
let catalog = TemplateCatalog::new(workspace.clone());
let err = catalog
.set_pointer(
TemplateScenario::Implementation,
"http://example.com/template.md",
)
.expect_err("http pointers must be rejected");
if let SpecmanError::Template(msg) = err {
assert!(msg.contains("https"));
} else {
panic!("expected template error for http pointer");
}
}
#[test]
fn worktype_resolves_to_distinct_embedded_defaults_and_cache_keys() {
let (_tempdir, workspace) = workspace_fixture();
let catalog = TemplateCatalog::new(workspace.clone());
let cases = vec![
(
"ref",
"embedded://scratch-ref",
"embedded-scratch-ref.md",
EMBEDDED_SCRATCH_REF,
),
(
"feat",
"embedded://scratch-feat",
"embedded-scratch-feat.md",
EMBEDDED_SCRATCH_FEAT,
),
(
"fix",
"embedded://scratch-fix",
"embedded-scratch-fix.md",
EMBEDDED_SCRATCH_FIX,
),
(
"revision",
"embedded://scratch-revision",
"embedded-scratch-revision.md",
EMBEDDED_SCRATCH_REVISION,
),
];
for (kind, locator, cache_name, expected) in cases {
let resolved = catalog
.resolve(TemplateScenario::WorkType(kind.to_string()))
.expect("work type resolve");
assert!(matches!(
resolved.provenance.tier,
TemplateTier::EmbeddedDefault
));
assert_eq!(resolved.provenance.locator, locator);
let cache_path = resolved
.provenance
.cache_path
.as_ref()
.expect("cache path set");
assert!(cache_path.ends_with(cache_name));
let abs_cache_path = workspace.root().join(cache_path);
assert_eq!(fs::read_to_string(abs_cache_path).unwrap(), expected);
}
}
#[test]
fn worktype_workspace_override_wins_and_no_generic_fallback_is_used() {
let (_tempdir, workspace) = workspace_fixture();
let catalog = TemplateCatalog::new(workspace.clone());
let templates_dir = workspace.dot_specman().join("templates");
fs::create_dir_all(templates_dir.join("scratch")).unwrap();
let ref_override = templates_dir.join("scratch/ref.md");
fs::write(&ref_override, "# custom ref scratch").unwrap();
let generic = templates_dir.join("scratch.md");
fs::write(&generic, "# generic scratch").unwrap();
let resolved_ref = catalog
.resolve(TemplateScenario::WorkType("ref".to_string()))
.expect("ref resolve");
assert!(matches!(
resolved_ref.provenance.tier,
TemplateTier::WorkspaceOverride
));
let resolved_feat = catalog
.resolve(TemplateScenario::WorkType("feat".to_string()))
.expect("feat resolve");
assert!(matches!(
resolved_feat.provenance.tier,
TemplateTier::EmbeddedDefault
));
let cache_path = resolved_feat.provenance.cache_path.unwrap();
let abs_cache_path = workspace.root().join(cache_path);
assert_eq!(
fs::read_to_string(abs_cache_path).unwrap(),
EMBEDDED_SCRATCH_FEAT
);
}
#[test]
fn scratchpad_scenario_is_rejected_and_unknown_work_types_raise_dedicated_error() {
let (_tempdir, workspace) = workspace_fixture();
let catalog = TemplateCatalog::new(workspace.clone());
let err = catalog
.resolve(TemplateScenario::ScratchPad)
.expect_err("scratch scenario rejected");
match err {
SpecmanError::Template(msg) => assert!(msg.contains("work type")),
other => panic!("expected template error, got {other:?}"),
}
let err = catalog
.resolve(TemplateScenario::WorkType("draft".to_string()))
.expect_err("draft should be rejected");
assert!(matches!(err, SpecmanError::UnknownWorkType(_)));
let err = catalog
.resolve(TemplateScenario::WorkType("weird".to_string()))
.expect_err("unknown should be rejected");
assert!(matches!(err, SpecmanError::UnknownWorkType(_)));
}
fn workspace_fixture() -> (tempfile::TempDir, WorkspacePaths) {
let tempdir = tempfile::tempdir().unwrap();
let root = tempdir.path().to_path_buf();
let dot_specman = root.join(".specman");
fs::create_dir_all(dot_specman.join("templates")).unwrap();
fs::create_dir_all(dot_specman.join("cache/templates")).unwrap();
fs::create_dir_all(root.join("spec")).unwrap();
fs::create_dir_all(root.join("impl")).unwrap();
(tempdir, WorkspacePaths::new(root, dot_specman))
}
trait LocatorExt {
fn matches_path(&self, expected: &Path) -> bool;
}
impl LocatorExt for TemplateLocator {
fn matches_path(&self, expected: &Path) -> bool {
match self {
TemplateLocator::FilePath(path) => path == expected,
_ => false,
}
}
}
}