use std::path::{Path, PathBuf};
use prov_graph::document::{Document, whole_file_format};
use prov_graph::error::{Error, Result};
use prov_graph::link;
use prov_graph::meta::{Mapping, Value};
use prov_store::fs::Storage;
use prov_store::index::IndexStore;
use crate::config::{ROOT_CONFIG_KEY, WorkspaceConfig};
use crate::identity::IdentityPolicy;
use crate::workspace::Workspace;
#[derive(Debug, Clone, PartialEq)]
pub struct Preset {
pub config: Mapping,
pub files: Vec<(PathBuf, Vec<u8>)>,
}
impl Preset {
pub fn builtin() -> Self {
let mut config = Mapping::new();
config.insert("created".into(), Value::String("created".into()));
config.insert("updated".into(), Value::String("updated".into()));
Self {
config,
files: Vec::new(),
}
}
pub fn load(dir: &Path) -> Result<Self> {
let mut node = None;
let mut files = Vec::new();
collect(dir, dir, &mut node, &mut files)?;
let Some((node_rel, text)) = node else {
return Err(Error::Structure(format!(
"{}: not a preset — no `prov.<yaml|json|toml|figl>` node in it",
dir.display()
)));
};
let doc = Document::parse(&node_rel, &text)?;
let config =
doc.meta.as_mapping().cloned().ok_or_else(|| {
Error::Structure(format!("{}: not a mapping", node_rel.display()))
})?;
let issues = crate::config::diagnose(&doc.meta);
if !issues.is_empty() {
let mut lines = format!("{}: not a preset prov can read:", node_rel.display());
for issue in issues {
use crate::config::ConfigIssueKind as K;
let what = match &issue.kind {
K::UnknownKey { suggestion } => {
format!("unknown key — did you mean `{suggestion}`?")
}
K::InvalidValue { value, expected } => {
format!(
"`{value}` is not a valid value (expected: {})",
expected.join(", ")
)
}
K::SpanningNotSingleParent { inverse } => {
format!("spanning relation's inverse `{inverse}` is not `cardinality: one`")
}
K::MalformedWorkspaceId { value } => {
format!("`{value}` is not a workspace name")
}
K::MalformedRoot { value } => format!("`{value}` is not a root name"),
K::NestNotSingleValued { field } => {
format!("nests by `{field}`, which is declared `type: seq`")
}
};
lines.push_str(&format!("\n {}: {what}", issue.key));
}
return Err(Error::Structure(lines));
}
Ok(Self { config, files })
}
pub fn without(mut self, key: &str) -> Self {
self.config.shift_remove(key);
self
}
}
fn collect(
root: &Path,
dir: &Path,
node: &mut Option<(PathBuf, String)>,
files: &mut Vec<(PathBuf, Vec<u8>)>,
) -> Result<()> {
let mut entries: Vec<_> = std::fs::read_dir(dir)
.map_err(|e| Error::Structure(format!("{}: {e}", dir.display())))?
.collect::<std::result::Result<_, _>>()
.map_err(|e| Error::Structure(format!("{}: {e}", dir.display())))?;
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
let rel = link::normalize(path.strip_prefix(root).unwrap_or(&path));
if path.is_dir() {
collect(root, &path, node, files)?;
continue;
}
let is_node = dir == root
&& path.file_stem().and_then(|s| s.to_str()) == Some("prov")
&& whole_file_format(&path).is_some();
if is_node {
if let Some((other, _)) = node {
return Err(Error::Structure(format!(
"{}: two nodes, {} and {} — a preset has one",
root.display(),
other.display(),
rel.display()
)));
}
let text = std::fs::read_to_string(&path)
.map_err(|e| Error::Structure(format!("{}: {e}", path.display())))?;
*node = Some((rel, text));
} else {
let bytes = std::fs::read(&path)
.map_err(|e| Error::Structure(format!("{}: {e}", path.display())))?;
files.push((rel, bytes));
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
pub enum Step {
Set { key: String, value: Value },
Same { key: String },
Differs {
key: String,
existing: Value,
incoming: Value,
},
Write { path: PathBuf },
Present { path: PathBuf },
Occupied { path: PathBuf },
}
impl Step {
pub fn is_collision(&self) -> bool {
matches!(self, Step::Differs { .. } | Step::Occupied { .. })
}
pub fn writes(&self) -> bool {
matches!(self, Step::Set { .. } | Step::Write { .. })
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Plan {
pub steps: Vec<Step>,
pub surface: PathBuf,
pub in_root_block: bool,
}
impl Plan {
pub fn is_clean(&self) -> bool {
!self.steps.iter().any(Step::is_collision)
}
pub fn writes_anything(&self) -> bool {
self.steps.iter().any(Step::writes)
}
pub fn collisions(&self) -> impl Iterator<Item = &Step> {
self.steps.iter().filter(|s| s.is_collision())
}
}
fn merges_by_entry(key: &str) -> bool {
matches!(
key,
"fields" | "views" | "exports" | "relations" | "metadata" | "references"
)
}
fn lookup<'a>(map: &'a Mapping, key: &str) -> Option<&'a Value> {
match key.split_once('.') {
None => map.get(key),
Some((head, tail)) => map.get(head).and_then(Value::as_mapping)?.get(tail),
}
}
fn deep_merge(base: &mut Mapping, overlay: &Mapping) {
for (key, value) in overlay {
match (base.get_mut(key), value) {
(Some(Value::Mapping(inner)), Value::Mapping(over)) => deep_merge(inner, over),
_ => {
base.insert(key.clone(), value.clone());
}
}
}
}
impl<FS: Storage, IdP: IdentityPolicy, Ix: IndexStore> Workspace<FS, IdP, Ix> {
pub async fn plan_preset(&self, root_doc: &Path, preset: &Preset) -> Result<Plan> {
let root_doc = link::normalize(root_doc);
let mut declared = Mapping::new();
let (_, root) = self.load(&root_doc).await?;
if let Some(block) = root.meta.get(ROOT_CONFIG_KEY).and_then(Value::as_mapping) {
deep_merge(&mut declared, block);
}
let config_doc = self.config_path(&root_doc).await?;
if let Some(doc_path) = &config_doc {
let (_, doc) = self.load(doc_path).await?;
if let Some(map) = doc.meta.as_mapping() {
deep_merge(&mut declared, map);
}
}
let defaults = WorkspaceConfig::default().to_mapping();
let mut steps = Vec::new();
let mut judge = |key: String, incoming: &Value| {
let step = match lookup(&declared, &key) {
Some(existing) if existing == incoming => Step::Same { key },
Some(existing) if lookup(&defaults, &key) == Some(existing) => Step::Set {
key,
value: incoming.clone(),
},
Some(existing) => Step::Differs {
key,
existing: existing.clone(),
incoming: incoming.clone(),
},
None => Step::Set {
key,
value: incoming.clone(),
},
};
steps.push(step);
};
for (key, value) in &preset.config {
match value.as_mapping() {
Some(entries) if merges_by_entry(key) => {
for (name, entry) in entries {
judge(format!("{key}.{name}"), entry);
}
}
_ => judge(key.clone(), value),
}
}
for (path, bytes) in &preset.files {
let path = link::normalize(path);
let step = if self.exists(&path).await? {
if self.read_bytes(&path).await? == *bytes {
Step::Present { path }
} else {
Step::Occupied { path }
}
} else {
Step::Write { path }
};
steps.push(step);
}
let (surface, in_root_block) = match config_doc {
Some(doc) => (doc, false),
None => (root_doc, true),
};
Ok(Plan {
steps,
surface,
in_root_block,
})
}
pub async fn apply_preset(&mut self, root_doc: &Path, preset: &Preset) -> Result<Plan> {
let plan = self.plan_preset(root_doc, preset).await?;
if !plan.is_clean() {
let mut lines = String::from("the preset collides with what the workspace declares:");
for step in plan.collisions() {
match step {
Step::Differs { key, .. } => {
lines.push_str(&format!("\n {key} is already declared, differently"));
}
Step::Occupied { path } => {
lines.push_str(&format!(
"\n {} exists with different contents",
path.display()
));
}
_ => {}
}
}
return Err(Error::Structure(lines));
}
if !plan.writes_anything() {
return Ok(plan);
}
let mut cs = self.change();
let sets: Vec<(&String, &Value)> = plan
.steps
.iter()
.filter_map(|s| match s {
Step::Set { key, value } => Some((key, value)),
_ => None,
})
.collect();
if !sets.is_empty() {
let (mut text, doc) = self.load(&plan.surface).await?;
for (key, value) in sets {
let dotted = if plan.in_root_block {
format!("{ROOT_CONFIG_KEY}.{key}")
} else {
key.clone()
};
text = prov_store::edit::set_meta_in_text(&text, doc.carrier, &dotted, value)?;
}
cs.write(&plan.surface, text);
}
for step in &plan.steps {
if let Step::Write { path } = step {
let bytes = preset
.files
.iter()
.find(|(p, _)| link::normalize(p) == *path)
.map(|(_, b)| b.clone())
.unwrap_or_default();
cs.expect_absent(path);
cs.write(path, bytes);
}
}
self.commit(cs).await?;
Ok(plan)
}
}
#[cfg(all(test, feature = "yaml"))]
mod tests {
use super::*;
use prov_graph::exec::block_on;
use prov_graph::fs::StdFs;
use prov_testkit::{read, write};
fn tempdir(tag: &str) -> PathBuf {
prov_testkit::scratch("preset", tag)
}
fn ws(dir: &Path) -> Workspace<StdFs> {
Workspace::builder(StdFs).root(dir).build()
}
fn tasks_preset() -> Preset {
let mut status = Mapping::new();
status.insert("values".into(), Value::String("closed".into()));
status.insert(
"vocabulary".into(),
Value::String("[Statuses](/vocab/statuses.yaml)".into()),
);
status.insert("default".into(), Value::String("open".into()));
let mut fields = Mapping::new();
fields.insert("status".into(), Value::Mapping(status));
let mut config = Mapping::new();
config.insert("fields".into(), Value::Mapping(fields));
config.insert("updated".into(), Value::String("updated".into()));
Preset {
config,
files: vec![(
PathBuf::from("vocab/statuses.yaml"),
b"title: Statuses\nvocabulary:\n field: status\n values: closed\nterms:\n open: {}\n done: {}\n".to_vec(),
)],
}
}
#[test]
fn a_fresh_workspace_takes_every_entry_and_store() {
let dir = tempdir("preset-fresh");
write(
&dir,
"index.md",
"---\ntitle: Root\nconfig: prov.yaml\n---\n",
);
write(&dir, "prov.yaml", "title: prov config\nupdated: ''\n");
let mut w = ws(&dir);
let plan = block_on(w.apply_preset(Path::new("index.md"), &tasks_preset())).unwrap();
assert!(plan.is_clean());
assert_eq!(plan.surface, PathBuf::from("prov.yaml"));
assert!(!plan.in_root_block);
assert!(matches!(&plan.steps[0], Step::Set { key, .. } if key == "fields.status"));
assert!(matches!(&plan.steps[1], Step::Set { key, .. } if key == "updated"));
assert!(
matches!(&plan.steps[2], Step::Write { path } if path == Path::new("vocab/statuses.yaml"))
);
let node = read(&dir, "prov.yaml");
assert!(node.contains("updated: updated"), "{node}");
assert!(
node.contains("status:") && node.contains("default: open"),
"{node}"
);
assert!(
node.starts_with("title: prov config\n"),
"format-preserving: {node}"
);
assert!(read(&dir, "vocab/statuses.yaml").contains("open: {}"));
let config = block_on(w.effective_config(Path::new("index.md"))).unwrap();
assert_eq!(config.updated, "updated");
assert_eq!(
config.fields["status"][0].default,
Some(Value::String("open".into()))
);
}
#[test]
fn applying_again_finds_nothing_to_do_and_a_change_is_a_collision() {
let dir = tempdir("preset-again");
write(
&dir,
"index.md",
"---\ntitle: Root\nconfig: prov.yaml\n---\n",
);
write(&dir, "prov.yaml", "title: prov config\n");
let mut w = ws(&dir);
block_on(w.apply_preset(Path::new("index.md"), &tasks_preset())).unwrap();
let again = block_on(w.plan_preset(Path::new("index.md"), &tasks_preset())).unwrap();
assert!(again.is_clean() && !again.writes_anything(), "{again:?}");
assert!(
again
.steps
.iter()
.all(|s| matches!(s, Step::Same { .. } | Step::Present { .. }))
);
block_on(w.apply_preset(Path::new("index.md"), &tasks_preset())).unwrap();
write(
&dir,
"vocab/statuses.yaml",
"title: Statuses\nterms:\n open: {}\n",
);
let mut preset = tasks_preset();
preset
.config
.insert("updated".into(), Value::String("modified".into()));
let plan = block_on(w.plan_preset(Path::new("index.md"), &preset)).unwrap();
assert!(!plan.is_clean());
assert_eq!(plan.collisions().count(), 2, "{plan:?}");
assert!(matches!(&plan.steps[1], Step::Differs { key, .. } if key == "updated"));
assert!(matches!(&plan.steps[2], Step::Occupied { .. }));
let err = block_on(w.apply_preset(Path::new("index.md"), &preset)).unwrap_err();
assert!(
err.to_string().contains("updated is already declared"),
"{err}"
);
assert!(
err.to_string().contains("vocab/statuses.yaml exists"),
"{err}"
);
assert_eq!(
read(&dir, "vocab/statuses.yaml"),
"title: Statuses\nterms:\n open: {}\n"
);
}
#[test]
fn a_workspace_without_a_config_document_takes_the_root_block() {
let dir = tempdir("preset-root-block");
write(
&dir,
"index.md",
"---\ntitle: Root\nprov:\n fixity: off\n---\nbody\n",
);
let mut w = ws(&dir);
let plan = block_on(w.apply_preset(Path::new("index.md"), &Preset::builtin())).unwrap();
assert!(plan.in_root_block);
assert_eq!(plan.surface, PathBuf::from("index.md"));
let root = read(&dir, "index.md");
assert!(root.contains(" fixity: off\n"), "kept: {root}");
assert!(
root.contains(" created: created\n") && root.contains(" updated: updated\n"),
"{root}"
);
assert!(root.ends_with("body\n"));
let config = block_on(w.effective_config(Path::new("index.md"))).unwrap();
assert_eq!(
(config.created.as_str(), config.updated.as_str()),
("created", "updated")
);
}
#[test]
fn a_preset_directory_loads_its_node_and_stores_and_refuses_a_typo() {
let dir = tempdir("preset-load");
write(&dir, "prov.yaml", "fields:\n status:\n default: open\n");
write(&dir, "vocab/statuses.yaml", "title: Statuses\n");
write(&dir, "vocab/deeper/terms.yaml", "title: T\n");
let preset = Preset::load(&dir).unwrap();
assert_eq!(
preset
.files
.iter()
.map(|(p, _)| p.clone())
.collect::<Vec<_>>(),
vec![
PathBuf::from("vocab/deeper/terms.yaml"),
PathBuf::from("vocab/statuses.yaml")
]
);
assert!(preset.config.contains_key("fields"));
write(&dir, "prov.yaml", "feilds:\n status:\n default: open\n");
let err = Preset::load(&dir).unwrap_err().to_string();
assert!(err.contains("feilds"), "{err}");
let empty = tempdir("preset-empty");
let err = Preset::load(&empty).unwrap_err().to_string();
assert!(err.contains("no `prov."), "{err}");
}
}