use std::path::{Path, PathBuf};
use fig::Segment;
use crate::document::{Document, EmbedStyle, MetaCarrier};
use crate::edit::MetaEditor;
use crate::error::{Error, Result};
use crate::fs::Storage;
use crate::identity::IdentityPolicy;
use crate::index::IndexStore;
use crate::link::{self, Link};
use crate::meta::Value;
use crate::workspace::Workspace;
use super::maintain::splice_body;
#[derive(Clone, Copy)]
enum ReformatAxis {
Format(fig::Format),
Embed(EmbedStyle),
}
impl<FS: Storage, IdP: IdentityPolicy, Ix: IndexStore> Workspace<FS, IdP, Ix> {
pub async fn convert_link_style(
&mut self,
file: &Path,
style: crate::link::LinkStyle,
recursive: bool,
) -> Result<Vec<PathBuf>> {
let file = link::normalize(file);
if !self.fs().try_exists(&self.root().join(&file)).await? {
return Err(Error::NotFound(file.to_path_buf()));
}
let targets = if recursive {
self.spanning_subtree(&file).await?
} else {
vec![file]
};
let mut cs = self.change();
let mut changed = Vec::new();
for path in &targets {
if let Some(text) = self.restyle_document(path, style).await? {
cs.write(path, text);
changed.push(path.clone());
}
}
self.commit(cs).await?;
Ok(changed)
}
pub async fn convert_meta_format(
&mut self,
file: &Path,
format: fig::Format,
recursive: bool,
) -> Result<Vec<PathBuf>> {
self.reformat_sweep(file, ReformatAxis::Format(format), recursive)
.await
}
pub async fn convert_meta_embed(
&mut self,
file: &Path,
style: EmbedStyle,
recursive: bool,
) -> Result<Vec<PathBuf>> {
self.reformat_sweep(file, ReformatAxis::Embed(style), recursive)
.await
}
async fn reformat_sweep(
&mut self,
file: &Path,
axis: ReformatAxis,
recursive: bool,
) -> Result<Vec<PathBuf>> {
let file = link::normalize(file);
if !self.fs().try_exists(&self.root().join(&file)).await? {
return Err(Error::NotFound(file.to_path_buf()));
}
let targets = if recursive {
self.spanning_subtree(&file).await?
} else {
vec![file.clone()]
};
let mut cs = self.change();
let mut changed = Vec::new();
for path in &targets {
let named = path == &file;
if let Some(text) = self.reformat_document(path, axis, named).await? {
cs.write(path, text);
changed.push(path.clone());
}
}
self.commit(cs).await?;
Ok(changed)
}
async fn reformat_document(
&self,
path: &Path,
axis: ReformatAxis,
named: bool,
) -> Result<Option<String>> {
let (_, doc) = self.load(path).await?;
let Some(mapping) = doc.meta.as_mapping() else {
return Ok(None); };
let kind = match doc.carrier {
Some(MetaCarrier::Fenced(kind)) => kind,
Some(MetaCarrier::WholeFile(_)) if named => {
return Err(Error::Structure(format!(
"{}: whole-file (separate) metadata — its format is its file \
extension and its shape is its own file; converting it is a move, \
not supported by `convert`",
path.display()
)));
}
_ => return Ok(None),
};
let (style, format) = match axis {
ReformatAxis::Format(format) => {
if kind.inner_format() == format {
return Ok(None);
}
(crate::document::embed_style_of(kind), format)
}
ReformatAxis::Embed(style) => {
if crate::document::embed_style_of(kind) == style {
return Ok(None);
}
if style == EmbedStyle::Separate {
return Err(Error::Structure(format!(
"{}: `separate` moves metadata into a sibling file and re-points \
its links — a move, not supported by `convert`",
path.display()
)));
}
(style, kind.inner_format())
}
};
let target = match crate::document::embed_carrier(style, format) {
Some(MetaCarrier::Fenced(target)) => target,
_ => {
let fmt = crate::config::metadata_format_str(format);
return Err(Error::Structure(format!(
"{}: a {} block cannot carry {fmt} — {fmt} has no delimiter syntax; \
use a code_block or HTML embedding",
path.display(),
style.as_config_str(),
)));
}
};
Ok(Some(crate::edit::reformat_block(
&doc.body, mapping, target,
)?))
}
async fn restyle_document(
&self,
path: &Path,
style: crate::link::LinkStyle,
) -> Result<Option<String>> {
let (text, doc) = self.load(path).await?;
let meta_rewritten =
restyle_frontmatter_links(&text, &doc, self.relations().relations(), path, style)?;
let final_text = restyle_body_links(&meta_rewritten, &doc.body, path, style);
Ok((final_text != text).then_some(final_text))
}
}
fn restyle_frontmatter_links(
text: &str,
doc: &Document,
relations: &[crate::relation::Relation],
file: &Path,
style: crate::link::LinkStyle,
) -> Result<String> {
let Some(carrier) = doc.carrier else {
return Ok(text.to_string()); };
let mut editor = MetaEditor::open(text, carrier)?;
let restyle = |raw: &str| -> Option<String> {
let link = Link::parse(raw);
if link.is_external()
|| link.id_target().is_some()
|| crate::title::is_alias_shaped(&link.target)
{
return None;
}
let resolved = link::resolve(file, &link.target);
Some(
link.with_target(link::path_text(style, file, &resolved))
.render(),
)
};
for relation in relations {
let Some(value) = doc.meta.get(&relation.name) else {
continue;
};
match value {
Value::String(raw) => {
if let Some(updated) = restyle(raw) {
editor
.replace_value(&[Segment::Key(&relation.name)], fig::Value::Str(updated))?;
}
}
Value::Sequence(items) => {
for (i, item) in items.iter().enumerate() {
if let Some(raw) = item.as_str()
&& let Some(updated) = restyle(raw)
{
editor.replace_value(
&[Segment::Key(&relation.name), Segment::Index(i)],
fig::Value::Str(updated),
)?;
}
}
}
_ => {}
}
}
editor.render()
}
fn restyle_body_links(
text: &str,
body: &str,
file: &Path,
style: crate::link::LinkStyle,
) -> String {
if body.is_empty() {
return text.to_string();
}
let mut new_body = String::with_capacity(body.len());
let mut cursor = 0;
let mut rewrote = false;
for bl in link::scan_body_links(file, body) {
if bl.id_target().is_some()
|| bl.link.is_external()
|| crate::title::is_alias_shaped(&bl.link.target)
{
continue;
}
let resolved = link::resolve(file, &bl.link.target);
let retargeted = bl
.link
.with_target(link::path_text(style, file, &resolved))
.render();
new_body.push_str(&body[cursor..bl.span.start]);
new_body.push_str(&retargeted);
cursor = bl.span.end;
rewrote = true;
}
if !rewrote {
return text.to_string();
}
new_body.push_str(&body[cursor..]);
splice_body(text, body, &new_body)
}
#[cfg(all(test, feature = "yaml"))]
mod tests {
use super::super::support::*;
use super::*;
use crate::link::LinkStyle;
#[test]
fn convert_restyles_one_files_links_leaving_the_rest_alone() {
let dir = tempdir("convert-linkstyle");
write(
&dir,
"index.md",
"---\ntitle: Root\ncontents:\n- '[Mid](/sub/mid.md)'\n---\n",
);
write(
&dir,
"sub/mid.md",
"---\ntitle: Mid\npart_of: /index.md\n---\nSee [the leaf](/sub/leaf.md).\n",
);
write(
&dir,
"sub/leaf.md",
"---\ntitle: Leaf\npart_of: /sub/mid.md\n---\n",
);
let n = block_on(ws(&dir).convert_link_style(
Path::new("sub/mid.md"),
LinkStyle::PlainRelative,
false,
))
.unwrap();
assert_eq!(n.len(), 1, "only the one file converted");
let mid = read(&dir, "sub/mid.md");
assert!(mid.contains("part_of: ../index.md"), "{mid}");
assert!(mid.contains("[the leaf](leaf.md)"), "{mid}");
assert!(
read(&dir, "index.md").contains("[Mid](/sub/mid.md)"),
"inbound untouched"
);
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn convert_recursive_covers_the_spanning_subtree_and_spares_id_and_external() {
let dir = tempdir("convert-recursive");
write(
&dir,
"index.md",
"---\ntitle: Root\ncontents:\n- a.md\n---\n",
);
write(
&dir,
"a.md",
"---\ntitle: A\npart_of: index.md\ncontents:\n- sub/b.md\n---\n\
See [ext](https://example.com) and [[id:ajp7eqb|pinned]].\n",
);
write(&dir, "sub/b.md", "---\ntitle: B\npart_of: ../a.md\n---\n");
let n = block_on(ws(&dir).convert_link_style(
Path::new("index.md"),
LinkStyle::MarkdownRoot,
true,
))
.unwrap();
assert_eq!(n.len(), 3, "root + a + b all converted");
let a = read(&dir, "a.md");
assert!(a.contains("part_of: /index.md"), "{a}");
assert!(a.contains("- /sub/b.md"), "{a}");
assert!(a.contains("[ext](https://example.com)"), "{a}");
assert!(a.contains("[[id:ajp7eqb|pinned]]"), "{a}");
assert!(
read(&dir, "sub/b.md").contains("part_of: /a.md"),
"descendant converted"
);
}
#[cfg(feature = "json")]
#[test]
fn convert_meta_format_reserializes_the_block_keeping_values_and_body() {
let dir = tempdir("convert-meta-json");
write(
&dir,
"index.md",
"---\ntitle: Root\ncontents:\n- '[Leaf](/leaf.md)'\n---\n# Root\n\nprose\n",
);
write(
&dir,
"leaf.md",
"---\ntitle: Leaf\npart_of: /index.md\n---\n",
);
let n =
block_on(ws(&dir).convert_meta_format(Path::new("index.md"), fig::Format::Json, false))
.unwrap();
assert_eq!(n.len(), 1, "only the named file converted");
let out = read(&dir, "index.md");
assert!(out.starts_with(";;;\n"), "delimited JSON now: {out}");
assert!(out.contains("\"title\": \"Root\""), "{out}");
assert!(
out.contains("[Leaf](/leaf.md)"),
"link value preserved: {out}"
);
assert!(out.ends_with("# Root\n\nprose\n"), "body untouched: {out}");
assert!(read(&dir, "leaf.md").starts_with("---\n"), "leaf untouched");
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[cfg(feature = "fig-lang")]
#[test]
fn convert_meta_format_keeps_the_embedding_shape_and_rejects_impossible_pairs() {
let dir = tempdir("convert-meta-fig");
write(&dir, "code.md", "```yaml\ntitle: Root\n```\nbody\n");
let n =
block_on(ws(&dir).convert_meta_format(Path::new("code.md"), fig::Format::Fig, false))
.unwrap();
assert_eq!(n.len(), 1);
let code = read(&dir, "code.md");
assert!(code.starts_with("```fig\n"), "code block kept: {code}");
assert!(code.contains("title = Root"), "fig dialect: {code}");
assert!(code.ends_with("body\n"), "body untouched: {code}");
write(&dir, "delim.md", "---\ntitle: Root\n---\nbody\n");
let err =
block_on(ws(&dir).convert_meta_format(Path::new("delim.md"), fig::Format::Fig, false))
.unwrap_err();
assert!(
err.to_string().contains("cannot carry fig"),
"clear diagnostic: {err}"
);
}
#[cfg(feature = "fig-lang")]
#[test]
fn convert_meta_format_renders_sequences_the_canonical_way() {
let dir = tempdir("convert-meta-seq");
write(
&dir,
"index.md",
"```yaml\ntitle: Root\ncontents:\n- '[Leaf](/leaf.md)'\n```\n# Root\n",
);
write(
&dir,
"leaf.md",
"```yaml\ntitle: Leaf\npart_of: /index.md\n```\n",
);
block_on(ws(&dir).convert_meta_format(Path::new("index.md"), fig::Format::Fig, false))
.unwrap();
let out = read(&dir, "index.md");
assert!(
out.contains("[Leaf](/leaf.md)") && !out.contains("= * ["),
"sequence stays well-formed: {out}"
);
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[cfg(feature = "fig-lang")]
#[test]
fn convert_meta_embed_reshapes_the_block_and_unblocks_fig() {
let dir = tempdir("convert-meta-embed");
write(
&dir,
"index.md",
"---\ntitle: Root\ncontents:\n- '[Leaf](/leaf.md)'\n---\n# Root\n",
);
write(
&dir,
"leaf.md",
"---\ntitle: Leaf\npart_of: /index.md\n---\n",
);
let n = block_on(ws(&dir).convert_meta_embed(
Path::new("index.md"),
EmbedStyle::CodeBlock,
false,
))
.unwrap();
assert_eq!(n.len(), 1);
let code = read(&dir, "index.md");
assert!(code.starts_with("```yaml\n"), "now a code block: {code}");
assert!(code.ends_with("# Root\n"), "body untouched: {code}");
block_on(ws(&dir).convert_meta_format(Path::new("index.md"), fig::Format::Fig, false))
.unwrap();
assert!(read(&dir, "index.md").starts_with("```fig\n"));
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
let err = block_on(ws(&dir).convert_meta_embed(
Path::new("leaf.md"),
EmbedStyle::Separate,
false,
))
.unwrap_err();
assert!(err.to_string().contains("separate"), "{err}");
}
#[cfg(feature = "json")]
#[test]
fn convert_meta_format_recursive_skips_no_ops_and_out_of_scope_documents() {
let dir = tempdir("convert-meta-recursive");
write(
&dir,
"index.md",
"---\ntitle: Root\ncontents:\n- a.md\n---\n",
);
write(
&dir,
"a.md",
";;;\n{\"title\": \"A\", \"part_of\": \"index.md\"}\n;;;\n",
);
let n =
block_on(ws(&dir).convert_meta_format(Path::new("index.md"), fig::Format::Json, true))
.unwrap();
assert_eq!(
n.len(),
1,
"only the root actually changed (a.md was already JSON)"
);
assert!(read(&dir, "index.md").starts_with(";;;\n"));
write(&dir, "conf.yaml", "title: Config\n");
let err = block_on(ws(&dir).convert_meta_format(
Path::new("conf.yaml"),
fig::Format::Json,
false,
))
.unwrap_err();
assert!(err.to_string().contains("whole-file"), "{err}");
}
}