use cyberbrain_core::frontmatter::validate_retention;
use cyberbrain_core::{Error, NoteKind, Result, Ring, slash};
use serde::Deserialize;
use std::path::{Path, PathBuf};
#[allow(dead_code)]
pub const DEFAULT_PLAN_TOML: &str = include_str!("default-plan.toml");
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ImportPlan {
#[serde(default = "one")]
pub version: u32,
#[serde(default)]
pub root: Option<PathBuf>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub accept_pii: bool,
#[serde(default)]
pub skip: Vec<SkipRule>,
#[serde(default, rename = "group")]
pub groups: Vec<Group>,
#[serde(skip)]
pub origin: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SkipRule {
pub paths: Vec<String>,
pub reason: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Group {
pub name: String,
pub paths: Vec<String>,
pub ring: Ring,
pub kind: NoteKind,
#[serde(default)]
pub split: Split,
#[serde(default = "two")]
pub heading_level: u8,
#[serde(default)]
pub delimiter: Option<String>,
#[serde(default)]
pub note_name: Option<String>,
#[serde(default)]
pub preamble_name: Option<String>,
#[serde(default)]
pub collisions: Collisions,
#[serde(default)]
pub existing: Existing,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub bereich: Option<String>,
#[serde(default)]
pub retention: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Split {
#[default]
File,
Heading,
Delimiter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Collisions {
#[default]
Report,
Hash,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Existing {
#[default]
Keep,
Overwrite,
}
fn one() -> u32 {
1
}
fn two() -> u8 {
2
}
const PLACEHOLDERS: [&str; 5] = ["{stem}", "{dir}", "{heading}", "{index}", "{hash}"];
impl ImportPlan {
pub fn parse(text: &str, origin: &str) -> Result<ImportPlan> {
let mut plan: ImportPlan = toml::from_str(text)
.map_err(|e| Error::Config(format!("import plan {origin}: {e}")))?;
plan.origin = origin.to_string();
plan.validate()?;
Ok(plan)
}
#[allow(dead_code)]
pub fn default_for(root: &Path) -> Result<ImportPlan> {
let mut plan = Self::parse(DEFAULT_PLAN_TOML, "built-in default plan")?;
plan.root = Some(root.to_path_buf());
Ok(plan)
}
pub fn root(&self) -> Result<&Path> {
self.root.as_deref().ok_or_else(|| {
Error::Config(format!(
"import plan {}: no `root` set; add `root = \"...\"` to the plan",
self.origin
))
})
}
fn validate(&self) -> Result<()> {
let bad = |what: String| Error::Config(format!("import plan {}: {what}", self.origin));
if self.version != 1 {
return Err(bad(format!(
"version {} is not supported (this reader knows version 1)",
self.version
)));
}
if self.groups.is_empty() {
return Err(bad("no [[group]] defined; nothing would be imported".into()));
}
for (i, s) in self.skip.iter().enumerate() {
if s.paths.is_empty() {
return Err(bad(format!("skip rule #{} has no paths", i + 1)));
}
if s.reason.trim().is_empty() {
return Err(bad(format!(
"skip rule #{} has no reason; a skip without a reason is a silent drop with paperwork",
i + 1
)));
}
}
let mut names = std::collections::HashSet::new();
for g in &self.groups {
let gbad = |what: String| bad(format!("group `{}`: {what}", g.name));
if g.name.trim().is_empty() {
return Err(bad("a group has no name".into()));
}
if !names.insert(g.name.as_str()) {
return Err(bad(format!("group name `{}` is used twice", g.name)));
}
if g.paths.is_empty() {
return Err(gbad("no paths".into()));
}
match g.split {
Split::Heading => {
if !(1..=6).contains(&g.heading_level) {
return Err(gbad(format!(
"heading_level {} is not in 1..=6",
g.heading_level
)));
}
}
Split::Delimiter => {
if g.delimiter.as_deref().is_none_or(|d| d.trim().is_empty()) {
return Err(gbad("split = \"delimiter\" needs a `delimiter`".into()));
}
}
Split::File => {}
}
for t in [&g.note_name, &g.preamble_name].into_iter().flatten() {
check_template(t).map_err(|why| gbad(format!("template `{t}`: {why}")))?;
}
if let Some(r) = &g.retention {
validate_retention(r).map_err(|why| gbad(format!("retention `{r}`: {why}")))?;
}
}
Ok(())
}
}
fn check_template(t: &str) -> std::result::Result<(), String> {
let mut rest = t;
let mut found = 0;
while let Some(start) = rest.find('{') {
let after = &rest[start..];
let Some(end) = after.find('}') else {
return Err("unclosed `{`".into());
};
let ph = &after[..=end];
if !PLACEHOLDERS.contains(&ph) {
return Err(format!(
"unknown placeholder {ph}; known: {}",
PLACEHOLDERS.join(", ")
));
}
found += 1;
rest = &after[end + 1..];
}
if found == 0 {
return Err("has no placeholder, so every item would get the same name".into());
}
Ok(())
}
pub fn load_plan(path: &Path) -> Result<ImportPlan> {
let text = std::fs::read_to_string(path).map_err(|e| Error::Io {
path: path.to_path_buf(),
source: e,
})?;
let mut plan = ImportPlan::parse(&text, &slash(path))?;
let dir = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
plan.root = Some(match plan.root.take() {
Some(r) if r.is_absolute() => r,
Some(r) => dir.join(r),
None => dir,
});
Ok(plan)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_plan_parses_and_validates() {
let p = ImportPlan::default_for(Path::new("/x")).unwrap();
assert_eq!(p.root.as_deref(), Some(Path::new("/x")));
assert!(p.groups.len() >= 5);
assert!(p.groups.iter().any(|g| g.ring == Ring::Invariant));
assert!(p.groups.iter().any(|g| g.ring == Ring::Protocol));
assert!(
p.groups
.iter()
.any(|g| g.paths.iter().any(|x| x.starts_with("PREREG_"))
&& g.ring == Ring::Knowledge),
"PREREG files land in ring 2"
);
}
#[test]
fn rejects_the_shapes_that_would_drop_things_quietly() {
let base = "version = 1\n[[group]]\nname = \"g\"\npaths = [\"*.md\"]\nring = 2\nkind = \"knowledge\"\n";
assert!(ImportPlan::parse(base, "t").is_ok());
for (text, needle) in [
("version = 2\n", "version 2"),
("version = 1\n", "no [[group]]"),
(
"version = 1\n[[skip]]\npaths = [\"x\"]\nreason = \" \"\n[[group]]\nname = \"g\"\npaths = [\"a\"]\nring = 2\nkind = \"bug\"\n",
"no reason",
),
(
"version = 1\n[[group]]\nname = \"g\"\npaths = [\"a\"]\nring = 7\nkind = \"bug\"\n",
"ring",
),
(
"version = 1\n[[group]]\nname = \"g\"\npaths = [\"a\"]\nring = 2\nkind = \"poem\"\n",
"poem",
),
(
"version = 1\n[[group]]\nname = \"g\"\npaths = [\"a\"]\nring = 2\nkind = \"bug\"\nsplit = \"delimiter\"\n",
"needs a `delimiter`",
),
(
"version = 1\n[[group]]\nname = \"g\"\npaths = [\"a\"]\nring = 2\nkind = \"bug\"\nnote_name = \"fixed\"\n",
"no placeholder",
),
(
"version = 1\n[[group]]\nname = \"g\"\npaths = [\"a\"]\nring = 2\nkind = \"bug\"\nnote_name = \"{nope}\"\n",
"unknown placeholder",
),
(
"version = 1\n[[group]]\nname = \"g\"\npaths = [\"a\"]\nring = 2\nkind = \"bug\"\ntypo = 1\n",
"unknown field",
),
(
"version = 1\n[[group]]\nname = \"g\"\npaths = [\"a\"]\nring = 2\nkind = \"bug\"\n[[group]]\nname = \"g\"\npaths = [\"b\"]\nring = 2\nkind = \"bug\"\n",
"used twice",
),
] {
let e = ImportPlan::parse(text, "t").unwrap_err().to_string();
assert!(e.contains(needle), "{text:?} -> {e}");
}
}
#[test]
fn load_resolves_root_against_the_plan_directory() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("plan.toml");
std::fs::write(
&p,
"version = 1\nroot = \"src\"\n[[group]]\nname = \"g\"\npaths = [\"*.md\"]\nring = 2\nkind = \"knowledge\"\n",
)
.unwrap();
let plan = load_plan(&p).unwrap();
assert_eq!(plan.root.unwrap(), dir.path().join("src"));
std::fs::write(
&p,
"version = 1\n[[group]]\nname = \"g\"\npaths = [\"*.md\"]\nring = 2\nkind = \"knowledge\"\n",
)
.unwrap();
assert_eq!(load_plan(&p).unwrap().root.unwrap(), dir.path());
}
}