use std::path::{Component, Path, PathBuf};
use crate::graph::unified::persistence::{GraphStorage, ManifestCheck};
use super::entrypoint::MacroBuildOptions;
const EXPAND_CACHE_EMPTY: &str = "the expand cache directory is empty; pass --expand-cache <DIR> \
(or the expand_cache field) naming a directory, or omit it to keep the recorded one";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MacroOptionsRequest {
pub cfg_flags: Option<Vec<String>>,
pub expand_cache_dir: Option<PathBuf>,
pub reset: bool,
}
impl MacroOptionsRequest {
#[must_use]
pub const fn empty() -> Self {
Self {
cfg_flags: None,
expand_cache_dir: None,
reset: false,
}
}
#[must_use]
pub fn from_flags(cfg_flags: &[String], expand_cache_dir: Option<&Path>, reset: bool) -> Self {
Self {
cfg_flags: if cfg_flags.is_empty() {
None
} else {
Some(cfg_flags.to_vec())
},
expand_cache_dir: expand_cache_dir
.map(|dir| std::path::absolute(dir).unwrap_or_else(|_| dir.to_path_buf())),
reset,
}
}
pub fn try_from_flags(
cfg_flags: &[String],
expand_cache_dir: Option<&Path>,
reset: bool,
) -> Result<Self, MacroRequestError> {
let given = Self {
cfg_flags: (!cfg_flags.is_empty()).then(|| cfg_flags.to_vec()),
expand_cache_dir: expand_cache_dir.map(Path::to_path_buf),
reset,
};
given.validate()?;
Ok(Self::from_flags(cfg_flags, expand_cache_dir, reset))
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.cfg_flags.is_none() && self.expand_cache_dir.is_none() && !self.reset
}
pub fn validate(&self) -> Result<(), MacroRequestError> {
for flag in self.cfg_flags.as_deref().unwrap_or_default() {
check_cfg_flag(flag)?;
}
if let Some(dir) = &self.expand_cache_dir {
if dir.as_os_str().is_empty() {
return Err(MacroRequestError::ExpandCacheEmpty);
}
if is_unanchorable_relative(dir) {
return Err(MacroRequestError::ExpandCacheNotAnchorable { dir: dir.clone() });
}
}
Ok(())
}
}
pub fn check_cfg_flag(flag: &str) -> Result<(), MacroRequestError> {
if flag.trim().is_empty() {
Err(MacroRequestError::CfgFlagEmpty)
} else if flag.trim() != flag {
Err(MacroRequestError::CfgFlagPadded {
flag: flag.to_string(),
})
} else {
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PathShape {
relative: bool,
starts_at_prefix_or_root: bool,
}
impl PathShape {
fn of(dir: &Path) -> Self {
Self {
relative: dir.is_relative(),
starts_at_prefix_or_root: matches!(
dir.components().next(),
Some(Component::Prefix(_) | Component::RootDir)
),
}
}
fn is_unanchorable(self) -> bool {
self.relative && self.starts_at_prefix_or_root
}
}
fn is_unanchorable_relative(dir: &Path) -> bool {
PathShape::of(dir).is_unanchorable()
}
fn absolute_root(root: &Path) -> PathBuf {
root.canonicalize()
.or_else(|_| std::path::absolute(root))
.unwrap_or_else(|_| root.to_path_buf())
}
fn anchor_to_root(root: &Path, dir: PathBuf) -> PathBuf {
if dir.is_relative() && !dir.as_os_str().is_empty() && !is_unanchorable_relative(&dir) {
root.join(dir)
} else {
dir
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MacroOptionsSource {
Explicit,
Recorded,
None,
}
impl MacroOptionsSource {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Explicit => "explicit",
Self::Recorded => "recorded",
Self::None => "none",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedMacroOptions {
pub options: MacroBuildOptions,
pub source: MacroOptionsSource,
}
impl ResolvedMacroOptions {
#[must_use]
pub fn none() -> Self {
Self {
options: MacroBuildOptions::default(),
source: MacroOptionsSource::None,
}
}
#[must_use]
pub fn describe(&self) -> String {
let mut parts = Vec::new();
if !self.options.cfg_flags.is_empty() {
let flags: Vec<String> = self
.options
.cfg_flags
.iter()
.map(|flag| {
if check_cfg_flag(flag).is_err() {
format!("{flag:?}")
} else {
flag.clone()
}
})
.collect();
parts.push(format!("cfg [{}]", flags.join(", ")));
}
if let Some(dir) = &self.options.expand_cache_dir {
parts.push(format!("expand cache {}", dir.display()));
}
let what = if parts.is_empty() {
"none".to_string()
} else {
parts.join(", ")
};
let from = match self.source {
MacroOptionsSource::Explicit => "from the flags",
MacroOptionsSource::Recorded => "recorded in the manifest",
MacroOptionsSource::None => "no record, no flags",
};
format!("{what} ({from})")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnreadableManifestRule {
Refuse,
TreatAsNoRecord,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MacroRequestError {
#[error(
"a cfg flag is empty; an empty flag names no predicate, so pass --cfg <PREDICATE> (or a \
non-empty cfg_flags item), or omit it to keep the recorded flags"
)]
CfgFlagEmpty,
#[error(
"cfg flag {flag:?} has leading or trailing whitespace, so it is not the predicate it \
names; pass --cfg <PREDICATE> (or a cfg_flags item) without the surrounding whitespace"
)]
CfgFlagPadded {
flag: String,
},
#[error("{}", EXPAND_CACHE_EMPTY)]
ExpandCacheEmpty,
#[error(
"expand cache directory {} is relative but carries a drive prefix or a root, so the \
directory it names depends on this process's drive and its working directory there; \
pass an absolute directory, or a relative one without a drive prefix or a root",
dir.display()
)]
ExpandCacheNotAnchorable {
dir: PathBuf,
},
}
#[derive(Debug, thiserror::Error)]
pub enum MacroOptionsError {
#[error("{}", expand_cache_missing_message(dir))]
ExpandCacheMissing {
dir: PathBuf,
},
#[error(
"expand cache directory {} is not valid UTF-8; the graph manifest records the \
directory as JSON text, so it cannot be recorded and reused as given; pass \
--expand-cache <DIR> (or the expand_cache field) naming a directory whose path is \
valid UTF-8, or --no-macro-options (reset_macro_options) to drop the recorded macro \
options",
dir.display()
)]
ExpandCachePathNotUtf8 {
dir: PathBuf,
},
#[error("{}", EXPAND_CACHE_EMPTY)]
ExpandCacheEmpty,
#[error(
"manifest at {} cannot be read ({reason}); repair with: sqry index --force <root>",
manifest_path.display()
)]
ManifestUnreadable {
manifest_path: PathBuf,
reason: String,
},
#[error(
"the index manifest records cfg flag {flag:?}, which {}; pass --cfg <PREDICATE> (or \
cfg_flags) to replace the recorded flags, or drop the record with --no-macro-options \
(reset_macro_options)",
recorded_cfg_flag_problem(flag)
)]
RecordedCfgFlagInvalid {
flag: String,
},
}
fn recorded_cfg_flag_problem(flag: &str) -> &'static str {
match check_cfg_flag(flag) {
Err(MacroRequestError::CfgFlagPadded { .. }) => {
"has leading or trailing whitespace, so it is not the predicate it names"
}
_ => "is empty or blank, so it names no predicate",
}
}
#[must_use]
pub fn expand_cache_missing_reason(dir: &Path) -> String {
if dir.as_os_str().is_empty() {
"the expand cache directory is empty, so it names no directory".to_string()
} else if is_unanchorable_relative(dir) {
format!(
"expand cache directory {} is relative but carries a drive prefix or a root, so the \
directory it names depends on this process's drive and its working directory there",
dir.display()
)
} else if dir.is_relative() {
format!(
"expand cache directory {} is relative and the directory being indexed could not be \
made absolute, so it cannot be anchored there",
dir.display()
)
} else {
format!(
"expand cache directory {} does not exist or is not a directory",
dir.display()
)
}
}
fn expand_cache_missing_message(dir: &Path) -> String {
const REMEDY: &str = "name a directory that exists (--expand-cache <DIR>, or the \
expand_cache field), or drop the macro options the manifest records \
(--no-macro-options, or reset_macro_options)";
format!("{}; {REMEDY}", expand_cache_missing_reason(dir))
}
pub fn resolve_macro_options(
root: &Path,
request: &MacroOptionsRequest,
rule: UnreadableManifestRule,
) -> Result<ResolvedMacroOptions, MacroOptionsError> {
if request
.expand_cache_dir
.as_deref()
.is_some_and(|dir| dir.as_os_str().is_empty())
{
return Err(MacroOptionsError::ExpandCacheEmpty);
}
let anchor = absolute_root(root);
let anchored;
let request = match &request.expand_cache_dir {
Some(dir) if dir.is_relative() => {
anchored = MacroOptionsRequest {
expand_cache_dir: Some(anchor_to_root(&anchor, dir.clone())),
..request.clone()
};
&anchored
}
_ => request,
};
let storage = GraphStorage::new(root);
let recorded = match storage.try_load_manifest() {
ManifestCheck::Present(manifest) => manifest.macro_options.as_ref().map(|record| {
let mut options = record.to_build_options();
options.expand_cache_dir = options
.expand_cache_dir
.map(|dir| anchor_to_root(&anchor, dir));
options
}),
ManifestCheck::Missing => None,
ManifestCheck::Corrupt(err) => match rule {
UnreadableManifestRule::Refuse => {
return Err(MacroOptionsError::ManifestUnreadable {
manifest_path: storage.manifest_path().to_path_buf(),
reason: err.to_string(),
});
}
UnreadableManifestRule::TreatAsNoRecord => None,
},
};
overlay(recorded, request)
}
fn overlay(
recorded: Option<MacroBuildOptions>,
request: &MacroOptionsRequest,
) -> Result<ResolvedMacroOptions, MacroOptionsError> {
let base = if request.reset {
None
} else {
recorded.filter(|options| !options.is_empty())
};
let explicit = request.cfg_flags.is_some() || request.expand_cache_dir.is_some();
if request.cfg_flags.is_none()
&& let Some(recorded) = &base
&& let Some(flag) = recorded
.cfg_flags
.iter()
.find(|flag| check_cfg_flag(flag).is_err())
{
return Err(MacroOptionsError::RecordedCfgFlagInvalid { flag: flag.clone() });
}
let mut options = base.clone().unwrap_or_default();
if let Some(flags) = &request.cfg_flags {
options.cfg_flags.clone_from(flags);
}
if let Some(dir) = &request.expand_cache_dir {
options.expand_cache_dir = Some(dir.clone());
}
if let Some(dir) = options.expand_cache_dir.take() {
if dir.is_relative() || !dir.is_dir() {
return Err(MacroOptionsError::ExpandCacheMissing { dir });
}
let canonical = dir.canonicalize().unwrap_or(dir);
if canonical.to_str().is_none() {
return Err(MacroOptionsError::ExpandCachePathNotUtf8 { dir: canonical });
}
options.expand_cache_dir = Some(canonical);
}
let source = if options.is_empty() {
MacroOptionsSource::None
} else if explicit {
MacroOptionsSource::Explicit
} else {
MacroOptionsSource::Recorded
};
Ok(ResolvedMacroOptions { options, source })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::unified::persistence::{BuildProvenance, MacroOptionsManifest, Manifest};
fn write_manifest(root: &Path, record: Option<MacroOptionsManifest>) {
let storage = GraphStorage::new(root);
std::fs::create_dir_all(storage.graph_dir()).expect("graph dir");
Manifest::new(
root.display().to_string(),
0,
0,
"sha",
BuildProvenance::new("test", "test"),
)
.with_macro_options(record)
.save(storage.manifest_path())
.expect("manifest written");
}
fn cache_dir(root: &Path, name: &str) -> PathBuf {
let dir = root.join(name);
std::fs::create_dir_all(&dir).expect("cache dir");
dir.canonicalize().expect("canonical cache dir")
}
#[test]
fn a_recorded_cfg_flag_that_names_no_predicate_is_refused() {
for (flag, problem) in [
("", "is empty or blank"),
(" ", "is empty or blank"),
(" test", "has leading or trailing whitespace"),
] {
let tmp = tempfile::tempdir().expect("tempdir");
write_manifest(
tmp.path(),
Some(MacroOptionsManifest {
cfg_flags: vec!["unix".to_string(), flag.to_string()],
expand_cache_dir: None,
}),
);
let refused = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest::empty(),
UnreadableManifestRule::Refuse,
)
.expect_err("a recorded flag that names no predicate is refused");
let rendered = refused.to_string();
assert!(
matches!(&refused, MacroOptionsError::RecordedCfgFlagInvalid { flag: recorded } if recorded == flag),
"{flag:?}: {refused:?}"
);
assert!(
rendered.contains(problem)
&& rendered.contains("--no-macro-options")
&& rendered.contains("cfg_flags"),
"{rendered}"
);
let replaced = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest {
cfg_flags: Some(vec!["test".to_string()]),
..MacroOptionsRequest::empty()
},
UnreadableManifestRule::Refuse,
)
.expect("replacing the recorded flags resolves");
assert_eq!(replaced.options.cfg_flags, vec!["test".to_string()]);
let reset = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest {
reset: true,
..MacroOptionsRequest::empty()
},
UnreadableManifestRule::Refuse,
)
.expect("dropping the record resolves");
assert_eq!(reset.options, MacroBuildOptions::default());
}
}
#[test]
fn no_manifest_resolves_to_none() {
let tmp = tempfile::tempdir().expect("tempdir");
let resolved = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest::empty(),
UnreadableManifestRule::Refuse,
)
.expect("resolves");
assert_eq!(resolved.source, MacroOptionsSource::None);
assert_eq!(resolved.options, MacroBuildOptions::default());
assert_eq!(resolved.describe(), "none (no record, no flags)");
}
#[test]
fn manifest_without_the_key_resolves_to_none() {
let tmp = tempfile::tempdir().expect("tempdir");
write_manifest(tmp.path(), None);
let resolved = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest::empty(),
UnreadableManifestRule::Refuse,
)
.expect("resolves");
assert_eq!(resolved.source, MacroOptionsSource::None);
}
#[test]
fn record_only_resolves_to_recorded() {
let tmp = tempfile::tempdir().expect("tempdir");
let cache = cache_dir(tmp.path(), "expand");
write_manifest(
tmp.path(),
Some(MacroOptionsManifest {
cfg_flags: vec!["test".to_string()],
expand_cache_dir: Some(cache.display().to_string()),
}),
);
let resolved = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest::empty(),
UnreadableManifestRule::Refuse,
)
.expect("resolves");
assert_eq!(resolved.source, MacroOptionsSource::Recorded);
assert_eq!(resolved.options.cfg_flags, vec!["test".to_string()]);
assert_eq!(
resolved.options.expand_cache_dir.as_deref(),
Some(cache.as_path())
);
assert_eq!(
resolved.describe(),
format!(
"cfg [test], expand cache {} (recorded in the manifest)",
cache.display()
)
);
}
#[test]
fn explicit_component_replaces_only_that_component() {
let tmp = tempfile::tempdir().expect("tempdir");
let cache = cache_dir(tmp.path(), "expand");
write_manifest(
tmp.path(),
Some(MacroOptionsManifest {
cfg_flags: vec!["test".to_string()],
expand_cache_dir: Some(cache.display().to_string()),
}),
);
let resolved = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest::from_flags(&["feature=serde".to_string()], None, false),
UnreadableManifestRule::Refuse,
)
.expect("resolves");
assert_eq!(resolved.source, MacroOptionsSource::Explicit);
assert_eq!(
resolved.options.cfg_flags,
vec!["feature=serde".to_string()]
);
assert_eq!(
resolved.options.expand_cache_dir.as_deref(),
Some(cache.as_path()),
"the recorded cache is kept when only the flags are explicit"
);
let other = cache_dir(tmp.path(), "other");
let resolved = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest::from_flags(&[], Some(&other), false),
UnreadableManifestRule::Refuse,
)
.expect("resolves");
assert_eq!(resolved.source, MacroOptionsSource::Explicit);
assert_eq!(
resolved.options.cfg_flags,
vec!["test".to_string()],
"the recorded flags are kept when only the cache is explicit"
);
assert_eq!(
resolved.options.expand_cache_dir.as_deref(),
Some(other.as_path())
);
}
#[test]
fn reset_clears_the_record() {
let tmp = tempfile::tempdir().expect("tempdir");
let cache = cache_dir(tmp.path(), "expand");
write_manifest(
tmp.path(),
Some(MacroOptionsManifest {
cfg_flags: vec!["test".to_string()],
expand_cache_dir: Some(cache.display().to_string()),
}),
);
let resolved = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest::from_flags(&[], None, true),
UnreadableManifestRule::Refuse,
)
.expect("resolves");
assert_eq!(resolved.source, MacroOptionsSource::None);
assert_eq!(resolved.options, MacroBuildOptions::default());
let resolved = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest::from_flags(&["unix".to_string()], None, true),
UnreadableManifestRule::Refuse,
)
.expect("resolves");
assert_eq!(resolved.source, MacroOptionsSource::Explicit);
assert_eq!(resolved.options.cfg_flags, vec!["unix".to_string()]);
assert_eq!(
resolved.options.expand_cache_dir, None,
"reset dropped the cache"
);
}
#[test]
fn missing_recorded_cache_dir_is_refused_by_name() {
let tmp = tempfile::tempdir().expect("tempdir");
let gone = tmp.path().join("gone-cache");
write_manifest(
tmp.path(),
Some(MacroOptionsManifest {
cfg_flags: vec![],
expand_cache_dir: Some(gone.display().to_string()),
}),
);
let err = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest::empty(),
UnreadableManifestRule::Refuse,
)
.expect_err("a missing recorded cache must refuse");
match &err {
MacroOptionsError::ExpandCacheMissing { dir } => assert_eq!(dir, &gone),
other => panic!("expected ExpandCacheMissing, got {other:?}"),
}
let rendered = err.to_string();
assert!(
rendered.contains(&gone.display().to_string())
&& rendered.contains("--no-macro-options"),
"the refusal names the directory and the way out: {rendered}"
);
let resolved = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest::from_flags(&[], None, true),
UnreadableManifestRule::Refuse,
)
.expect("reset drops the missing record");
assert_eq!(resolved.source, MacroOptionsSource::None);
let err = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest::from_flags(&[], Some(&gone), true),
UnreadableManifestRule::Refuse,
)
.expect_err("an explicit missing cache must refuse");
assert!(matches!(err, MacroOptionsError::ExpandCacheMissing { .. }));
}
#[test]
fn a_relative_expand_cache_resolves_against_the_workspace_root() {
let root = tempfile::tempdir().expect("root");
let name = "w4-relative-cache-under-the-root-only";
std::fs::create_dir(root.path().join(name)).expect("cache dir");
let request = MacroOptionsRequest {
cfg_flags: None,
expand_cache_dir: Some(PathBuf::from(name)),
reset: false,
};
let resolved = resolve_macro_options(root.path(), &request, UnreadableManifestRule::Refuse)
.expect("a relative directory under the root resolves");
assert_eq!(
resolved.options.expand_cache_dir,
Some(root.path().join(name).canonicalize().expect("canonical"))
);
}
#[test]
fn a_relative_expand_cache_never_resolves_against_the_process_directory() {
assert!(
Path::new("src").is_dir(),
"precondition: the test runs from the crate root, which holds src"
);
let root = tempfile::tempdir().expect("root");
let request = MacroOptionsRequest {
cfg_flags: None,
expand_cache_dir: Some(PathBuf::from("src")),
reset: false,
};
let err = resolve_macro_options(root.path(), &request, UnreadableManifestRule::Refuse)
.expect_err("src is not under the workspace root");
assert!(
matches!(err, MacroOptionsError::ExpandCacheMissing { ref dir } if dir == &canonical(root.path()).join("src")),
"refused as missing under the root, got {err:?}"
);
}
#[test]
fn an_empty_expand_cache_is_refused() {
let root = tempfile::tempdir().expect("root");
let request = MacroOptionsRequest {
cfg_flags: None,
expand_cache_dir: Some(PathBuf::new()),
reset: false,
};
let err = resolve_macro_options(root.path(), &request, UnreadableManifestRule::Refuse)
.expect_err("an empty expand cache directory names none");
assert!(
matches!(err, MacroOptionsError::ExpandCacheEmpty),
"refused as empty, got {err:?}"
);
}
#[test]
fn from_flags_makes_a_relative_expand_cache_absolute_against_the_caller() {
let request = MacroOptionsRequest::from_flags(&[], Some(Path::new("rel/cache")), false);
let cwd = std::env::current_dir().expect("cwd");
assert_eq!(
request.expand_cache_dir,
Some(cwd.join("rel").join("cache"))
);
let absolute_dir = cwd.join("abs").join("cache");
let absolute = MacroOptionsRequest::from_flags(&[], Some(&absolute_dir), false);
assert_eq!(absolute.expand_cache_dir, Some(absolute_dir));
}
#[cfg(unix)]
#[test]
fn a_non_utf8_expand_cache_directory_is_refused_by_name() {
use std::os::unix::ffi::OsStringExt;
let tmp = tempfile::tempdir().expect("tempdir");
let mut name = b"expand-cache-".to_vec();
name.push(0xff);
let dir = tmp.path().join(std::ffi::OsString::from_vec(name));
std::fs::create_dir_all(&dir).expect("non-UTF-8 dir");
assert!(dir.to_str().is_none(), "instrument: the name is not UTF-8");
let err = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest::from_flags(&[], Some(&dir), false),
UnreadableManifestRule::Refuse,
)
.expect_err("a directory the manifest cannot record must be refused");
let canonical = dir.canonicalize().expect("canonical");
match &err {
MacroOptionsError::ExpandCachePathNotUtf8 { dir: refused } => {
assert_eq!(refused, &canonical, "the canonical form is named");
}
other => panic!("expected ExpandCachePathNotUtf8, got {other:?}"),
}
let rendered = err.to_string();
println!("{rendered}");
assert!(rendered.contains("not valid UTF-8"), "{rendered}");
assert!(rendered.contains("JSON text"), "{rendered}");
assert!(
rendered.contains("--expand-cache") && rendered.contains("--no-macro-options"),
"{rendered}"
);
}
#[cfg(unix)]
#[test]
fn a_utf8_symlink_into_a_non_utf8_directory_is_refused() {
use std::os::unix::ffi::OsStringExt;
let tmp = tempfile::tempdir().expect("tempdir");
let mut name = b"target-".to_vec();
name.push(0xfe);
let target = tmp.path().join(std::ffi::OsString::from_vec(name));
std::fs::create_dir_all(&target).expect("non-UTF-8 target");
let link = tmp.path().join("expand-cache-link");
std::os::unix::fs::symlink(&target, &link).expect("symlink");
assert!(link.to_str().is_some(), "instrument: the request is UTF-8");
let err = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest::from_flags(&[], Some(&link), false),
UnreadableManifestRule::Refuse,
)
.expect_err("the canonical path is not UTF-8");
assert!(
matches!(err, MacroOptionsError::ExpandCachePathNotUtf8 { .. }),
"expected ExpandCachePathNotUtf8, got {err:?}"
);
}
#[test]
fn a_non_ascii_utf8_expand_cache_directory_is_accepted() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = cache_dir(tmp.path(), "expand-cach\u{e9}-\u{3a9}");
assert!(
!dir.to_str().expect("UTF-8").is_ascii(),
"instrument: the name is not ASCII"
);
let resolved = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest::from_flags(&[], Some(&dir), false),
UnreadableManifestRule::Refuse,
)
.expect("a non-ASCII UTF-8 directory is accepted");
assert_eq!(resolved.source, MacroOptionsSource::Explicit);
assert_eq!(
resolved.options.expand_cache_dir.as_deref(),
Some(dir.as_path())
);
}
#[test]
fn unreadable_manifest_follows_the_rule() {
let tmp = tempfile::tempdir().expect("tempdir");
let storage = GraphStorage::new(tmp.path());
std::fs::create_dir_all(storage.graph_dir()).expect("graph dir");
std::fs::write(storage.manifest_path(), b"{").expect("unparseable manifest");
let err = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest::empty(),
UnreadableManifestRule::Refuse,
)
.expect_err("refuse");
match &err {
MacroOptionsError::ManifestUnreadable { manifest_path, .. } => {
assert_eq!(manifest_path, storage.manifest_path());
}
other => panic!("expected ManifestUnreadable, got {other:?}"),
}
let resolved = resolve_macro_options(
tmp.path(),
&MacroOptionsRequest::empty(),
UnreadableManifestRule::TreatAsNoRecord,
)
.expect("fallback");
assert_eq!(resolved.source, MacroOptionsSource::None);
}
#[test]
fn from_flags_treats_an_empty_cfg_list_as_absent() {
let request = MacroOptionsRequest::from_flags(&[], None, false);
assert!(request.is_empty());
assert_eq!(request, MacroOptionsRequest::empty());
let request = MacroOptionsRequest::from_flags(&["a".to_string()], None, false);
assert_eq!(request.cfg_flags, Some(vec!["a".to_string()]));
assert!(!request.is_empty());
}
#[test]
fn source_wire_spellings_are_stable() {
assert_eq!(MacroOptionsSource::Explicit.as_str(), "explicit");
assert_eq!(MacroOptionsSource::Recorded.as_str(), "recorded");
assert_eq!(MacroOptionsSource::None.as_str(), "none");
}
fn request_for(dir: &str) -> MacroOptionsRequest {
MacroOptionsRequest {
cfg_flags: None,
expand_cache_dir: Some(PathBuf::from(dir)),
reset: false,
}
}
fn resolved_dir(root: &Path, request: &MacroOptionsRequest) -> PathBuf {
resolve_macro_options(root, request, UnreadableManifestRule::Refuse)
.expect("resolves")
.options
.expand_cache_dir
.expect("an expand cache directory")
}
#[test]
fn validate_refuses_an_empty_or_blank_cfg_flag() {
for flags in [
vec![String::new()],
vec![" ".to_string()],
vec!["test".to_string(), String::new()],
] {
let request = MacroOptionsRequest {
cfg_flags: Some(flags.clone()),
..MacroOptionsRequest::empty()
};
assert_eq!(
request.validate(),
Err(MacroRequestError::CfgFlagEmpty),
"{flags:?}"
);
}
let rendered = MacroRequestError::CfgFlagEmpty.to_string();
assert!(
rendered.contains("cfg flag is empty") && rendered.contains("cfg_flags"),
"{rendered}"
);
for flags in [
vec!["test".to_string()],
vec![],
vec!["feature=serde".to_string()],
] {
let request = MacroOptionsRequest {
cfg_flags: Some(flags.clone()),
..MacroOptionsRequest::empty()
};
assert_eq!(request.validate(), Ok(()), "{flags:?}");
}
assert_eq!(MacroOptionsRequest::empty().validate(), Ok(()));
}
#[test]
fn validate_refuses_a_cfg_flag_with_surrounding_whitespace() {
for (flags, padded) in [
(vec![" test".to_string()], " test"),
(vec!["test ".to_string()], "test "),
(vec!["\ttest".to_string()], "\ttest"),
(vec!["unix".to_string(), "test\n".to_string()], "test\n"),
] {
let request = MacroOptionsRequest {
cfg_flags: Some(flags.clone()),
..MacroOptionsRequest::empty()
};
assert_eq!(
request.validate(),
Err(MacroRequestError::CfgFlagPadded {
flag: padded.to_string()
}),
"{flags:?}"
);
assert_eq!(
check_cfg_flag(padded),
Err(MacroRequestError::CfgFlagPadded {
flag: padded.to_string()
})
);
}
let rendered = MacroRequestError::CfgFlagPadded {
flag: " test".to_string(),
}
.to_string();
assert!(
rendered.contains("\" test\"") && rendered.contains("leading or trailing whitespace"),
"{rendered}"
);
for flag in ["test", "feature=serde", "feature = \"x\""] {
assert_eq!(check_cfg_flag(flag), Ok(()), "{flag:?}");
}
for flag in ["", " ", "\t"] {
assert_eq!(
check_cfg_flag(flag),
Err(MacroRequestError::CfgFlagEmpty),
"{flag:?}"
);
}
}
#[test]
fn the_anchoring_decision_refuses_a_relative_path_with_a_prefix_or_a_root() {
let shape = |relative, starts_at_prefix_or_root| PathShape {
relative,
starts_at_prefix_or_root,
};
assert!(shape(true, true).is_unanchorable(), r"C:cache, \cache");
assert!(!shape(true, false).is_unanchorable(), "cache");
assert!(!shape(false, true).is_unanchorable(), r"/cache, C:\cache");
assert_eq!(PathShape::of(Path::new("cache")), shape(true, false));
assert_eq!(PathShape::of(Path::new("../cache")), shape(true, false));
assert_eq!(PathShape::of(Path::new("./cache/sub")), shape(true, false));
assert_eq!(PathShape::of(Path::new("")), shape(true, false));
let absolute = std::env::current_dir().expect("cwd").join("cache");
assert_eq!(PathShape::of(&absolute), shape(false, true), "{absolute:?}");
assert!(!is_unanchorable_relative(Path::new("cache")));
assert!(!is_unanchorable_relative(&absolute));
}
#[cfg(windows)]
#[test]
fn path_shapes_are_read_from_windows_paths() {
let shape = |relative, starts_at_prefix_or_root| PathShape {
relative,
starts_at_prefix_or_root,
};
assert_eq!(PathShape::of(Path::new("C:cache")), shape(true, true));
assert_eq!(PathShape::of(Path::new(r"\cache")), shape(true, true));
assert_eq!(PathShape::of(Path::new(r"\cache\sub")), shape(true, true));
assert_eq!(PathShape::of(Path::new(r"C:\cache")), shape(false, true));
assert_eq!(
PathShape::of(Path::new(r"\\server\share\cache")),
shape(false, true)
);
assert_eq!(PathShape::of(Path::new(r"cache\sub")), shape(true, false));
for unanchorable in ["C:cache", r"\cache"] {
assert!(
is_unanchorable_relative(Path::new(unanchorable)),
"{unanchorable}"
);
}
for anchorable in [r"C:\cache", "cache", r"..\cache"] {
assert!(
!is_unanchorable_relative(Path::new(anchorable)),
"{anchorable}"
);
}
}
#[test]
fn the_missing_reason_is_chosen_by_shape() {
assert_eq!(
expand_cache_missing_reason(Path::new("")),
"the expand cache directory is empty, so it names no directory"
);
let relative = expand_cache_missing_reason(Path::new("cache"));
assert!(
relative.contains("cache is relative")
&& relative.contains("could not be made absolute"),
"{relative}"
);
let absolute = std::env::current_dir().expect("cwd").join("no-such-cache");
assert_eq!(
expand_cache_missing_reason(&absolute),
format!(
"expand cache directory {} does not exist or is not a directory",
absolute.display()
)
);
for dir in [Path::new(""), Path::new("cache"), absolute.as_path()] {
let reason = expand_cache_missing_reason(dir);
assert!(!reason.contains("--no-macro-options"), "{reason}");
let message = MacroOptionsError::ExpandCacheMissing {
dir: dir.to_path_buf(),
}
.to_string();
assert!(
message.starts_with(&reason) && message.contains("--no-macro-options"),
"{message}"
);
}
}
#[test]
fn validate_refuses_an_empty_expand_cache_and_accepts_ordinary_ones() {
assert_eq!(
request_for("").validate(),
Err(MacroRequestError::ExpandCacheEmpty)
);
assert_eq!(
MacroRequestError::ExpandCacheEmpty.to_string(),
MacroOptionsError::ExpandCacheEmpty.to_string(),
"both refusals of the empty directory read the same"
);
for dir in ["cache", "./cache", "cache/", ".", "..", "a/../b"] {
assert_eq!(request_for(dir).validate(), Ok(()), "{dir}");
}
}
#[test]
fn try_from_flags_checks_then_makes_absolute() {
assert_eq!(
MacroOptionsRequest::try_from_flags(&[String::new()], None, false),
Err(MacroRequestError::CfgFlagEmpty)
);
assert_eq!(
MacroOptionsRequest::try_from_flags(&[], Some(Path::new("")), false),
Err(MacroRequestError::ExpandCacheEmpty)
);
let accepted = MacroOptionsRequest::try_from_flags(
&["test".to_string()],
Some(Path::new("rel/cache")),
true,
)
.expect("ordinary flags are accepted");
assert_eq!(
accepted,
MacroOptionsRequest::from_flags(
&["test".to_string()],
Some(Path::new("rel/cache")),
true
)
);
}
#[cfg(windows)]
#[test]
fn a_relative_expand_cache_with_a_prefix_or_root_is_refused() {
let root = tempfile::tempdir().expect("root");
for dir in ["C:cache", r"\cache"] {
let request = request_for(dir);
match request.validate() {
Err(MacroRequestError::ExpandCacheNotAnchorable { dir: named }) => {
assert_eq!(named, PathBuf::from(dir));
}
other => panic!("{dir}: expected ExpandCacheNotAnchorable, got {other:?}"),
}
let rendered = request.validate().unwrap_err().to_string();
assert!(
rendered.contains(dir) && rendered.contains("drive prefix or a root"),
"{rendered}"
);
let err = resolve_macro_options(root.path(), &request, UnreadableManifestRule::Refuse)
.expect_err("the resolver never resolves it through the working directory");
match &err {
MacroOptionsError::ExpandCacheMissing { dir: named } => {
assert_eq!(named, &PathBuf::from(dir), "left unanchored");
}
other => panic!("{dir}: expected ExpandCacheMissing, got {other:?}"),
}
assert!(err.to_string().contains("drive prefix or a root"), "{err}");
write_manifest(
root.path(),
Some(MacroOptionsManifest {
cfg_flags: vec![],
expand_cache_dir: Some(dir.to_string()),
}),
);
let err = resolve_macro_options(
root.path(),
&MacroOptionsRequest::empty(),
UnreadableManifestRule::Refuse,
)
.expect_err("a recorded prefixed directory is refused too");
assert!(
matches!(&err, MacroOptionsError::ExpandCacheMissing { dir: named } if named == &PathBuf::from(dir)),
"{err:?}"
);
}
let cache = cache_dir(root.path(), "abs-cache");
assert_eq!(request_for(&cache.display().to_string()).validate(), Ok(()));
}
#[test]
fn a_relative_recorded_expand_cache_resolves_against_the_workspace_root() {
assert!(Path::new("src").is_dir(), "precondition: cwd holds src");
let root = tempfile::tempdir().expect("root");
let name = "w4-recorded-relative-cache";
std::fs::create_dir(root.path().join(name)).expect("cache dir");
write_manifest(
root.path(),
Some(MacroOptionsManifest {
cfg_flags: vec![],
expand_cache_dir: Some(name.to_string()),
}),
);
let resolved = resolve_macro_options(
root.path(),
&MacroOptionsRequest::empty(),
UnreadableManifestRule::Refuse,
)
.expect("a relative record under the root resolves");
assert_eq!(resolved.source, MacroOptionsSource::Recorded);
assert_eq!(
resolved.options.expand_cache_dir,
Some(root.path().join(name).canonicalize().expect("canonical"))
);
write_manifest(
root.path(),
Some(MacroOptionsManifest {
cfg_flags: vec![],
expand_cache_dir: Some("src".to_string()),
}),
);
let err = resolve_macro_options(
root.path(),
&MacroOptionsRequest::empty(),
UnreadableManifestRule::Refuse,
)
.expect_err("src is not under the workspace root");
assert!(
matches!(&err, MacroOptionsError::ExpandCacheMissing { dir } if dir == &canonical(root.path()).join("src")),
"refused as missing under the root, got {err:?}"
);
}
#[test]
fn an_empty_recorded_expand_cache_is_refused_not_rooted() {
let root = tempfile::tempdir().expect("root");
write_manifest(
root.path(),
Some(MacroOptionsManifest {
cfg_flags: vec![],
expand_cache_dir: Some(String::new()),
}),
);
let err = resolve_macro_options(
root.path(),
&MacroOptionsRequest::empty(),
UnreadableManifestRule::Refuse,
)
.expect_err("an empty record names no directory");
match &err {
MacroOptionsError::ExpandCacheMissing { dir } => {
assert!(dir.as_os_str().is_empty(), "left empty: {dir:?}");
}
other => panic!("expected ExpandCacheMissing, got {other:?}"),
}
let rendered = err.to_string();
assert!(rendered.contains("is empty"), "{rendered}");
}
#[test]
fn a_file_is_refused_as_not_a_directory() {
let root = tempfile::tempdir().expect("root");
let file = root.path().join("cache-file");
std::fs::write(&file, b"not a directory").expect("file");
let err = resolve_macro_options(
root.path(),
&request_for("cache-file"),
UnreadableManifestRule::Refuse,
)
.expect_err("a file is no expand cache");
assert!(
matches!(&err, MacroOptionsError::ExpandCacheMissing { dir } if dir == &file),
"{err:?}"
);
let rendered = err.to_string();
assert!(
rendered.contains("does not exist or is not a directory")
&& rendered.contains("expand_cache field")
&& rendered.contains("reset_macro_options"),
"{rendered}"
);
}
#[test]
fn ordinary_relative_spellings_record_the_canonical_directory() {
let outer = tempfile::tempdir().expect("outer");
let root = outer.path().join("ws");
std::fs::create_dir_all(root.join("x")).expect("cache dir");
let canonical_root = root.canonicalize().expect("canonical root");
let canonical_x = canonical_root.join("x");
for (input, expected) in [
(".", canonical_root.clone()),
("..", outer.path().canonicalize().expect("canonical outer")),
("./x", canonical_x.clone()),
("x/", canonical_x.clone()),
("x/../x", canonical_x.clone()),
] {
assert_eq!(
resolved_dir(&root, &request_for(input)).as_os_str(),
expected.as_os_str(),
"{input}"
);
}
assert_eq!(
resolved_dir(
&root,
&request_for(&root.join(".").join("x").display().to_string())
)
.as_os_str(),
canonical_x.as_os_str()
);
}
#[cfg(unix)]
#[test]
fn a_utf8_symlink_records_its_target() {
let root = tempfile::tempdir().expect("root");
let target = cache_dir(root.path(), "real-cache");
std::os::unix::fs::symlink(&target, root.path().join("link-cache")).expect("symlink");
assert_eq!(
resolved_dir(root.path(), &request_for("link-cache")).as_os_str(),
target.as_os_str()
);
}
#[test]
fn describe_quotes_a_blank_cfg_flag() {
let resolved = ResolvedMacroOptions {
options: MacroBuildOptions {
cfg_flags: vec![String::new(), "test".to_string(), " unix".to_string()],
expand_cache_dir: None,
},
source: MacroOptionsSource::Recorded,
};
assert_eq!(
resolved.describe(),
"cfg [\"\", test, \" unix\"] (recorded in the manifest)"
);
}
fn canonical(path: &Path) -> PathBuf {
path.canonicalize().expect("canonical")
}
fn relative_to_cwd(target: &Path) -> PathBuf {
let cwd = canonical(&std::env::current_dir().expect("cwd"));
let target = canonical(target);
let cwd_parts: Vec<Component<'_>> = cwd.components().collect();
let target_parts: Vec<Component<'_>> = target.components().collect();
let common = cwd_parts
.iter()
.zip(&target_parts)
.take_while(|(a, b)| a == b)
.count();
let mut relative = PathBuf::new();
for _ in common..cwd_parts.len() {
relative.push("..");
}
for part in &target_parts[common..] {
relative.push(part.as_os_str());
}
assert!(relative.is_relative(), "{}", relative.display());
relative
}
#[test]
fn a_relative_root_anchors_a_relative_directory_absolutely() {
let root = tempfile::tempdir().expect("root");
let name = "w4-relative-root-cache";
std::fs::create_dir(root.path().join(name)).expect("cache dir");
let relative_root = relative_to_cwd(root.path());
let expected = canonical(&root.path().join(name));
write_manifest(
root.path(),
Some(MacroOptionsManifest {
cfg_flags: vec![],
expand_cache_dir: Some(name.to_string()),
}),
);
let resolved = resolve_macro_options(
&relative_root,
&MacroOptionsRequest::empty(),
UnreadableManifestRule::Refuse,
)
.expect("a relative record under a relative root resolves");
assert_eq!(resolved.source, MacroOptionsSource::Recorded);
assert_eq!(resolved.options.expand_cache_dir.as_ref(), Some(&expected));
let resolved = resolve_macro_options(
&relative_root,
&request_for(name),
UnreadableManifestRule::Refuse,
)
.expect("a relative request under a relative root resolves");
assert_eq!(resolved.options.expand_cache_dir.as_ref(), Some(&expected));
let err = resolve_macro_options(
&relative_root,
&request_for("no-such-cache"),
UnreadableManifestRule::Refuse,
)
.expect_err("a name not under the root is refused");
match &err {
MacroOptionsError::ExpandCacheMissing { dir } => {
assert!(dir.is_absolute(), "{}", dir.display());
assert_eq!(dir, &canonical(root.path()).join("no-such-cache"));
}
other => panic!("expected ExpandCacheMissing, got {other:?}"),
}
}
#[test]
fn a_missing_relative_root_is_made_absolute_against_the_working_directory() {
let root = Path::new("r7s3-no-such-root");
assert!(!root.exists(), "precondition: the root does not exist");
let expected = std::env::current_dir()
.expect("cwd")
.join(root)
.join("cache");
let err =
resolve_macro_options(root, &request_for("cache"), UnreadableManifestRule::Refuse)
.expect_err("a directory under a missing root is missing");
match &err {
MacroOptionsError::ExpandCacheMissing { dir } => {
assert_eq!(dir, &expected, "anchored at the absolute root");
}
other => panic!("expected ExpandCacheMissing, got {other:?}"),
}
let rendered = err.to_string();
assert!(
rendered.contains("does not exist or is not a directory")
&& !rendered.contains("could not be made absolute"),
"{rendered}"
);
}
#[test]
fn a_nested_relative_root_anchors_a_relative_directory_absolutely() {
let parent = tempfile::tempdir().expect("parent");
let ws = parent.path().join("outer").join("ws");
std::fs::create_dir_all(ws.join("nested-cache")).expect("cache dir");
let expected = canonical(&ws.join("nested-cache"));
let nested = relative_to_cwd(&ws);
let via_parent = relative_to_cwd(parent.path())
.join("outer")
.join("ws")
.join("..")
.join("ws");
for root in [&nested, &via_parent] {
let resolved = resolve_macro_options(
root,
&request_for("nested-cache"),
UnreadableManifestRule::Refuse,
)
.unwrap_or_else(|err| panic!("{}: {err}", root.display()));
assert_eq!(
resolved.options.expand_cache_dir.as_ref(),
Some(&expected),
"{}",
root.display()
);
}
}
#[cfg(unix)]
#[test]
fn a_symlinked_root_anchors_a_relative_directory_in_its_target() {
let parent = tempfile::tempdir().expect("parent");
let ws = parent.path().join("ws");
std::fs::create_dir_all(ws.join("link-root-cache")).expect("cache dir");
let link = parent.path().join("link");
std::os::unix::fs::symlink(&ws, &link).expect("symlink");
let expected = canonical(&ws.join("link-root-cache"));
write_manifest(
&ws,
Some(MacroOptionsManifest {
cfg_flags: vec![],
expand_cache_dir: Some("link-root-cache".to_string()),
}),
);
for root in [link.clone(), relative_to_cwd(parent.path()).join("link")] {
let resolved = resolve_macro_options(
&root,
&MacroOptionsRequest::empty(),
UnreadableManifestRule::Refuse,
)
.unwrap_or_else(|err| panic!("{}: {err}", root.display()));
assert_eq!(resolved.options.expand_cache_dir.as_ref(), Some(&expected));
let resolved = resolve_macro_options(
&root,
&request_for("link-root-cache"),
UnreadableManifestRule::Refuse,
)
.unwrap_or_else(|err| panic!("{}: {err}", root.display()));
assert_eq!(resolved.options.expand_cache_dir.as_ref(), Some(&expected));
}
}
}