use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use tatara_lisp::DeriveTataraDomain;
use crate::SpecError;
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoercionContext {
Interpolation,
ToString,
FodOutput,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoercedForm {
StorePathWithContext,
PlainString,
}
#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
#[tatara(keyword = "defcoercion-rule")]
pub struct CoercionRule {
pub name: String,
#[serde(rename = "inputKind")]
pub input_kind: String,
pub context: CoercionContext,
#[serde(rename = "copyToStore")]
pub copy_to_store: bool,
#[serde(rename = "tracksContext")]
pub tracks_context: bool,
pub yields: CoercedForm,
}
impl CoercionRule {
#[must_use]
pub fn is_consistent(&self) -> bool {
if self.input_kind == "path" {
self.copy_to_store == self.tracks_context
} else {
true
}
}
}
#[derive(Debug, Clone)]
pub struct CoerceInput {
pub kind: String,
pub literal: String,
pub prior_context: BTreeSet<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoercedValue {
pub rendered: String,
pub context: BTreeSet<String>,
pub copied: bool,
}
pub trait StoreEnvironment {
fn exists(&self, path: &str) -> bool;
fn copy_to_store(&mut self, path: &str) -> Result<String, SpecError>;
}
pub fn coerce<E: StoreEnvironment>(
rule: &CoercionRule,
env: &mut E,
input: &CoerceInput,
) -> Result<CoercedValue, SpecError> {
let mut context = input.prior_context.clone();
if input.kind == "path" && rule.copy_to_store {
if !env.exists(&input.literal) {
return Err(SpecError::Interp {
phase: "coerce-path".into(),
message: format!("path {:?} does not exist", input.literal),
});
}
let store_path = env.copy_to_store(&input.literal)?;
if rule.tracks_context {
context.insert(store_path.clone());
}
return Ok(CoercedValue {
rendered: store_path,
context,
copied: true,
});
}
Ok(CoercedValue {
rendered: input.literal.clone(),
context: if rule.tracks_context { context } else { BTreeSet::new() },
copied: false,
})
}
pub const CANONICAL_COERCION_LISP: &str = include_str!("../specs/coercion.lisp");
pub fn load_canonical_rules() -> Result<Vec<CoercionRule>, SpecError> {
crate::loader::load_all::<CoercionRule>(CANONICAL_COERCION_LISP)
}
#[cfg(test)]
mod tests {
use super::*;
struct MockStore;
impl StoreEnvironment for MockStore {
fn exists(&self, _path: &str) -> bool {
true
}
fn copy_to_store(&mut self, path: &str) -> Result<String, SpecError> {
let base = path.rsplit('/').next().unwrap_or(path);
Ok(format!("/nix/store/deadbeef-{base}"))
}
}
fn rule(name: &str) -> CoercionRule {
load_canonical_rules()
.unwrap()
.into_iter()
.find(|r| r.name == name)
.unwrap_or_else(|| panic!("canonical rule {name}"))
}
#[test]
fn canonical_rules_load_and_are_all_consistent() {
let rules = load_canonical_rules().unwrap();
assert!(rules.len() >= 3);
for r in &rules {
assert!(r.is_consistent(), "rule {} is inconsistent (copy≠track)", r.name);
}
}
#[test]
fn interpolated_path_copies_to_store_and_tracks_context() {
let mut env = MockStore;
let out = coerce(
&rule("path-in-interpolation"),
&mut env,
&CoerceInput {
kind: "path".into(),
literal: "/tmp/src/foo.c".into(),
prior_context: BTreeSet::new(),
},
)
.unwrap();
assert!(out.copied);
assert_eq!(out.rendered, "/nix/store/deadbeef-foo.c");
assert!(out.context.contains("/nix/store/deadbeef-foo.c"));
}
#[test]
fn tostring_path_stays_plain_no_copy_no_context() {
let mut env = MockStore;
let out = coerce(
&rule("path-in-tostring"),
&mut env,
&CoerceInput {
kind: "path".into(),
literal: "/tmp/src/foo.c".into(),
prior_context: BTreeSet::new(),
},
)
.unwrap();
assert!(!out.copied);
assert_eq!(out.rendered, "/tmp/src/foo.c");
assert!(out.context.is_empty());
}
#[test]
fn string_context_propagates_through_coercion() {
let mut env = MockStore;
let mut prior = BTreeSet::new();
prior.insert("/nix/store/aaa-m.dev".to_string());
let out = coerce(
&rule("string-in-interpolation"),
&mut env,
&CoerceInput {
kind: "string".into(),
literal: "/nix/store/aaa-m.dev/include".into(),
prior_context: prior,
},
)
.unwrap();
assert!(out.context.contains("/nix/store/aaa-m.dev"), "context must propagate");
}
#[test]
fn interpolated_path_that_does_not_exist_errors() {
struct Empty;
impl StoreEnvironment for Empty {
fn exists(&self, _p: &str) -> bool {
false
}
fn copy_to_store(&mut self, _p: &str) -> Result<String, SpecError> {
unreachable!()
}
}
let mut env = Empty;
let err = coerce(
&rule("path-in-interpolation"),
&mut env,
&CoerceInput {
kind: "path".into(),
literal: "/nope".into(),
prior_context: BTreeSet::new(),
},
);
assert!(err.is_err());
}
}