use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use crate::document::MetaCarrier;
use crate::error::{Error, Result};
use crate::fs::Storage;
use crate::identity::IdentityPolicy;
use crate::index::IndexStore;
use crate::link;
use crate::workspace::Workspace;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StructurePlan {
pub synthesized: Vec<SynthNode>,
pub adoptions: Vec<Adoption>,
}
impl StructurePlan {
pub fn is_empty(&self) -> bool {
self.synthesized.is_empty() && self.adoptions.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SynthNode {
pub path: PathBuf,
pub parent: PathBuf,
pub title: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Adoption {
pub child: PathBuf,
pub parent: PathBuf,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PlanOutcome {
pub synthesized: Vec<PathBuf>,
pub adopted: Vec<PathBuf>,
pub skipped: Vec<(PathBuf, String)>,
}
fn existing_node(files: &[PathBuf]) -> Option<PathBuf> {
let stem_is = |p: &Path, want: &str| {
p.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|s| s.eq_ignore_ascii_case(want))
};
files
.iter()
.find(|p| stem_is(p, "index"))
.or_else(|| files.iter().find(|p| stem_is(p, "readme")))
.cloned()
}
impl<FS: Storage, Id, Ix: IndexStore> Workspace<FS, Id, Ix> {
pub async fn plan_mirror(&self, root_doc: &Path) -> Result<StructurePlan> {
let root_doc = link::normalize(root_doc);
let (_, root) = self.load(&root_doc).await?;
if root.content_attr().is_some() || matches!(root.carrier, Some(MetaCarrier::WholeFile(_)))
{
return Err(Error::Structure(
"mirror import needs a combined-document root (folder-notes inherit its \
grammar); re-run with flat adoption instead"
.into(),
));
}
let ext = root_doc
.extension()
.and_then(|e| e.to_str())
.unwrap_or("md")
.to_string();
let files: Vec<PathBuf> = self
.content_documents()
.await?
.into_iter()
.filter(|p| *p != root_doc)
.collect();
let mut by_dir: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
let mut dirs: BTreeSet<PathBuf> = BTreeSet::new();
dirs.insert(PathBuf::new()); for file in &files {
let dir = file.parent().unwrap_or(Path::new("")).to_path_buf();
by_dir.entry(dir.clone()).or_default().push(file.clone());
let mut d = dir;
while !d.as_os_str().is_empty() {
dirs.insert(d.clone());
d = d.parent().unwrap_or(Path::new("")).to_path_buf();
}
}
let mut node: BTreeMap<PathBuf, PathBuf> = BTreeMap::new();
let mut synth_dirs: BTreeSet<PathBuf> = BTreeSet::new();
for dir in &dirs {
if dir.as_os_str().is_empty() {
node.insert(dir.clone(), root_doc.clone());
continue;
}
match by_dir.get(dir).and_then(|files| existing_node(files)) {
Some(n) => {
node.insert(dir.clone(), n);
}
None => {
node.insert(
dir.clone(),
link::normalize(dir.join(format!("index.{ext}"))),
);
synth_dirs.insert(dir.clone());
}
}
}
let mut synth_sorted: Vec<&PathBuf> = synth_dirs.iter().collect();
synth_sorted.sort_by_key(|d| d.components().count());
let synthesized: Vec<SynthNode> = synth_sorted
.into_iter()
.map(|dir| SynthNode {
path: node[dir].clone(),
parent: node[dir.parent().unwrap_or(Path::new(""))].clone(),
title: link::path_to_title(dir),
})
.collect();
let mut adoptions: Vec<Adoption> = Vec::new();
for dir in &dirs {
if dir.as_os_str().is_empty() || synth_dirs.contains(dir) {
continue;
}
adoptions.push(Adoption {
child: node[dir].clone(),
parent: node[dir.parent().unwrap_or(Path::new(""))].clone(),
});
}
for (dir, dir_files) in &by_dir {
let n = &node[dir];
for file in dir_files {
if file != n {
adoptions.push(Adoption {
child: file.clone(),
parent: n.clone(),
});
}
}
}
Ok(StructurePlan {
synthesized,
adoptions,
})
}
}
impl<FS: Storage, IdP: IdentityPolicy, Ix: IndexStore> Workspace<FS, IdP, Ix> {
pub async fn apply_plan(&mut self, plan: &StructurePlan) -> Result<PlanOutcome> {
let mut outcome = PlanOutcome::default();
for synth in &plan.synthesized {
self.create_titled(&synth.path, &synth.parent, Some(&synth.title))
.await?;
outcome.synthesized.push(synth.path.clone());
}
for edge in &plan.adoptions {
match self.adopt(&edge.child, &edge.parent).await {
Ok(()) => outcome.adopted.push(edge.child.clone()),
Err(e) => outcome.skipped.push((edge.child.clone(), e.to_string())),
}
}
Ok(outcome)
}
}
#[cfg(all(test, feature = "yaml"))]
mod tests {
use super::*;
use crate::exec::block_on;
use crate::fs::StdFs;
fn write(dir: &Path, rel: &str, text: &str) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
}
fn read(dir: &Path, rel: &str) -> String {
std::fs::read_to_string(dir.join(rel)).unwrap()
}
fn tempdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("prov-intake-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn ws(dir: &Path) -> Workspace<StdFs> {
Workspace::builder(StdFs).root(dir).build()
}
#[test]
fn mirror_synthesizes_a_folder_note_for_a_bare_directory() {
let dir = tempdir("mirror-synth");
write(&dir, "index.md", "---\ntitle: Home\n---\n");
write(&dir, "notes/one.md", "---\ntitle: One\n---\nfirst\n");
write(&dir, "notes/two.md", "---\ntitle: Two\n---\nsecond\n");
let plan = block_on(ws(&dir).plan_mirror(Path::new("index.md"))).unwrap();
assert_eq!(plan.synthesized.len(), 1);
assert_eq!(plan.synthesized[0].path, PathBuf::from("notes/index.md"));
assert_eq!(plan.synthesized[0].parent, PathBuf::from("index.md"));
assert_eq!(plan.synthesized[0].title, "Notes");
assert!(plan.adoptions.iter().any(
|a| a.child == Path::new("notes/one.md") && a.parent == Path::new("notes/index.md")
));
assert!(plan.adoptions.iter().any(
|a| a.child == Path::new("notes/two.md") && a.parent == Path::new("notes/index.md")
));
let outcome = block_on(ws(&dir).apply_plan(&plan)).unwrap();
assert_eq!(outcome.synthesized, vec![PathBuf::from("notes/index.md")]);
assert_eq!(outcome.adopted.len(), 2);
assert!(outcome.skipped.is_empty());
let folder = read(&dir, "notes/index.md");
assert!(folder.contains("title: Notes"), "{folder}");
assert!(folder.contains("/index.md"), "part_of the root: {folder}");
assert!(
read(&dir, "index.md").contains("[Notes](/notes/index.md)"),
"root's contents entry uses the folder title as its label: {}",
read(&dir, "index.md")
);
assert!(
folder.contains("one.md") && folder.contains("two.md"),
"{folder}"
);
assert!(
read(&dir, "notes/one.md").contains("first"),
"body preserved"
);
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn mirror_uses_an_existing_folder_index_instead_of_synthesizing() {
let dir = tempdir("mirror-existing");
write(&dir, "index.md", "---\ntitle: Home\n---\n");
write(
&dir,
"notes/index.md",
"---\ntitle: Notes Home\n---\nfolder intro\n",
);
write(&dir, "notes/leaf.md", "---\ntitle: Leaf\n---\nleaf\n");
let plan = block_on(ws(&dir).plan_mirror(Path::new("index.md"))).unwrap();
assert!(
plan.synthesized.is_empty(),
"existing index means nothing to synthesize"
);
assert!(
plan.adoptions.iter().any(
|a| a.child == Path::new("notes/index.md") && a.parent == Path::new("index.md")
)
);
assert!(
plan.adoptions
.iter()
.any(|a| a.child == Path::new("notes/leaf.md")
&& a.parent == Path::new("notes/index.md"))
);
block_on(ws(&dir).apply_plan(&plan)).unwrap();
assert!(
read(&dir, "notes/index.md").contains("folder intro"),
"existing index body kept"
);
assert!(
read(&dir, "notes/index.md").contains("title: Notes Home"),
"existing title kept"
);
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn mirror_nests_folder_notes_for_a_deep_tree() {
let dir = tempdir("mirror-deep");
write(&dir, "index.md", "---\ntitle: Home\n---\n");
write(&dir, "a/b/deep.md", "---\ntitle: Deep\n---\ndeep\n");
let plan = block_on(ws(&dir).plan_mirror(Path::new("index.md"))).unwrap();
let synth: Vec<_> = plan.synthesized.iter().map(|n| n.path.clone()).collect();
assert_eq!(
synth,
vec![PathBuf::from("a/index.md"), PathBuf::from("a/b/index.md")]
);
block_on(ws(&dir).apply_plan(&plan)).unwrap();
assert!(
read(&dir, "a/index.md").contains("a/b/index.md"),
"a contains a/b"
);
assert!(
read(&dir, "a/b/index.md").contains("deep.md"),
"a/b contains the leaf"
);
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn mirror_leaves_loose_binaries_alone() {
let dir = tempdir("mirror-no-attach");
write(&dir, "index.md", "---\ntitle: Home\n---\n");
write(&dir, "notes/one.md", "---\ntitle: One\n---\nfirst\n");
write(&dir, "cover.jpg", "\u{fffd}binary");
write(&dir, "src/main.rs", "fn main() {}\n");
let plan = block_on(ws(&dir).plan_mirror(Path::new("index.md"))).unwrap();
block_on(ws(&dir).apply_plan(&plan)).unwrap();
assert!(read(&dir, "notes/index.md").contains("one.md"));
assert!(
!dir.join("cover.jpg.yaml").exists(),
"no sidecar for the image"
);
assert!(
!dir.join("src/index.md").exists(),
"an all-code directory gets no folder-note"
);
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn mirror_refuses_a_separated_root() {
let dir = tempdir("mirror-separated");
write(&dir, "index.yaml", "title: Root\ncontent: index.md\n");
write(&dir, "index.md", "# Root\n");
write(&dir, "loose.md", "---\ntitle: Loose\n---\n");
let err = block_on(ws(&dir).plan_mirror(Path::new("index.yaml"))).unwrap_err();
assert!(err.to_string().contains("combined-document root"), "{err}");
}
}