use std::path::{Path, PathBuf};
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_roteiro");
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"-c",
"init.defaultBranch=main",
])
.args(args)
.current_dir(dir)
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed");
}
fn write(dir: &Path, rel: &str, content: &str) {
let path = dir.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).expect("mkdir");
std::fs::write(path, content).expect("write");
}
fn fresh_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("roteiro-render-cli-{tag}-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).expect("mkdir");
dir
}
fn source(marker: &str, lines: usize) -> String {
use std::fmt::Write as _;
let mut s = format!("// {marker}: deferred\n");
for i in 1..lines {
let _ = writeln!(s, "pub const N{i}: u32 = {i};");
}
s
}
#[test]
fn render_docs_builds_site_from_adrs_and_assets() {
let dir = fresh_dir("docs");
git(&dir, &["init", "-q"]);
write(&dir, "website/public/style.css", "body{color:#111}\n");
write(&dir, "website/public/index.html", "<h1>Home</h1>\n");
write(&dir, "website/public/favicon.svg", "<svg/>\n");
write(
&dir,
"docs/adr/0001-example.md",
"---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001: Example\n\n## Context\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
);
write(&dir, "docs/adr/README.md", "index, not an ADR\n");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
let out = Command::new(BIN)
.args(["render", "docs", "--out", "site"])
.current_dir(&dir)
.output()
.expect("run render");
assert!(out.status.success(), "render failed: {out:?}");
let site = dir.join("site");
assert!(site.join("style.css").exists());
assert!(site.join("index.html").exists());
assert!(site.join("favicon.svg").exists());
let page = std::fs::read_to_string(site.join("adr/0001-example.html")).expect("adr page");
assert!(page.starts_with("<!doctype html>"));
assert!(page.contains("<h1 id=\"adr-0001-example\">ADR-0001: Example</h1>"));
assert!(page.contains("<table>"), "GFM table should render");
assert!(!page.contains("adr-id"), "frontmatter should be stripped");
assert!(page.contains("← Back to roteiro.dev"));
assert!(
!site.join("adr/README.html").exists(),
"README is not an ADR page"
);
let index = std::fs::read_to_string(site.join("adr/index.html")).expect("index");
assert!(index.contains("<a href=\"0001-example.html\">ADR-0001: Example</a>"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn render_obsidian_home_scopes_debt_by_the_ignore_config() {
let dir = fresh_dir("obsidian");
git(&dir, &["init", "-q"]);
write(&dir, "src/lib.rs", &source("TODO", 100));
write(&dir, "vendor/dep.rs", &source("FIXME", 100));
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
let render = |dir: &Path| -> String {
let out = Command::new(BIN)
.args(["render", "obsidian", "--out", "vault"])
.current_dir(dir)
.env("ROTEIRO_HOME", dir)
.output()
.expect("run render");
assert!(out.status.success(), "render obsidian failed: {out:?}");
std::fs::read_to_string(dir.join("vault/_Home.md")).expect("_Home.md")
};
let home = render(&dir);
assert!(
home.contains("| fixme | 1 |") && home.contains("| todo | 1 |"),
"both markers counted with no ignore config: {home}"
);
assert!(
home.contains("vendor/dep.rs") && home.contains("src/lib.rs"),
"both files ranked with no ignore config: {home}"
);
write(&dir, "roteiro.toml", "[debt]\nignore = [\"vendor/**\"]\n");
let home = render(&dir);
assert!(
!home.contains("fixme"),
"ignored marker must not reach the category totals: {home}"
);
assert!(
home.contains("| todo | 1 |"),
"the marker still in scope is still counted: {home}"
);
assert!(
!home.contains("vendor"),
"ignored file must not reach the density table: {home}"
);
assert!(
home.contains("src/lib.rs"),
"the file still in scope is still ranked: {home}"
);
std::fs::remove_dir_all(&dir).ok();
}
fn document() -> String {
use std::fmt::Write as _;
let mut s = String::from(
"# Working offline\n\nRoteiro is **offline-capable**.\n\n\
| Host | What |\n| --- | --- |\n| `example.com` | models |\n\n\
```sh\nroteiro model pull\n```\n\n## Detail\n\n",
);
for i in 0..60 {
let _ = writeln!(s, "Paragraph {i} of the document body.\n");
}
s
}
#[test]
fn render_obsidian_gives_a_prose_note_its_whole_source() {
let dir = fresh_dir("obsidian-prose");
git(&dir, &["init", "-q"]);
let doc = document();
write(&dir, "docs/OFFLINE.md", &doc);
write(
&dir,
"docs/adr/0001-example.md",
"---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001: Example\n\n## Context\n\nBecause.\n",
);
write(&dir, "src/lib.rs", "/// Doc comment.\npub fn f() {}\n");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
let out = Command::new(BIN)
.args(["render", "obsidian", "--out", "vault"])
.current_dir(&dir)
.env("ROTEIRO_HOME", &dir)
.output()
.expect("run render");
assert!(out.status.success(), "render obsidian failed: {out:?}");
let note = std::fs::read_to_string(dir.join("vault/file-docs-OFFLINE.md.md")).expect("note");
assert!(
note.contains(doc.trim()),
"the note must reproduce its source: {note}"
);
assert!(
note.contains("Paragraph 59 of the document body."),
"the tail of the document past the extraction cap is present: {note}"
);
assert!(
note.contains("\n| Host | What |\n") && note.contains("\n```sh\n"),
"a table and a fence need their own lines: {note}"
);
let adr = std::fs::read_to_string(dir.join("vault/adr-0001.md")).expect("adr note");
assert!(
!adr.contains("## Context\n\nBecause."),
"an adr note is its title, status and links — not the file: {adr}"
);
let section = std::fs::read_to_string(dir.join("vault/adr-0001-context.md")).expect("section");
assert!(
!section.contains("# ADR-0001: Example"),
"a section note must not carry the whole document: {section}"
);
let sym = std::fs::read_to_string(dir.join("vault/sym-rust-src-lib.rs-f.md")).expect("sym");
assert!(
sym.contains("## Content\n\nDoc comment."),
"doc comments render as before: {sym}"
);
assert!(
!sym.contains("pub fn f()"),
"a symbol note does not gain its file's source: {sym}"
);
let rs = std::fs::read_to_string(dir.join("vault/file-src-lib.rs.md")).expect("rs note");
assert!(
!rs.contains("pub fn f()"),
"a source file is not prose: {rs}"
);
std::fs::remove_dir_all(&dir).ok();
}
fn adr_document() -> String {
use std::fmt::Write as _;
let mut s = String::from(
"---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n\
# ADR-0001: Example\n\n| | |\n|---|---|\n| **State** | Accepted |\n\n\
## Context\n\n",
);
for i in 0..40 {
let _ = writeln!(s, "CONTEXTWORD paragraph {i} about the situation.\n");
}
let _ = write!(s, "## Decision\n\n");
for i in 0..40 {
let _ = writeln!(s, "DECISIONWORD paragraph {i} about the choice.\n");
}
s
}
#[test]
fn render_obsidian_gives_an_adr_section_note_its_own_section() {
let dir = fresh_dir("obsidian-adr");
git(&dir, &["init", "-q"]);
let doc = adr_document();
write(&dir, "docs/adr/0001-example.md", &doc);
write(&dir, "src/lib.rs", "/// Doc comment.\npub fn f() {}\n");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
let out = Command::new(BIN)
.args(["render", "obsidian", "--out", "vault"])
.current_dir(&dir)
.env("ROTEIRO_HOME", &dir)
.output()
.expect("run render");
assert!(out.status.success(), "render obsidian failed: {out:?}");
let read = |name: &str| {
std::fs::read_to_string(dir.join("vault").join(name))
.unwrap_or_else(|e| panic!("{name}: {e}"))
};
let context = read("adr-0001-context.md");
assert!(
context.contains("## Content"),
"the defect: the note had no content at all: {context}"
);
assert!(
context.contains("CONTEXTWORD paragraph 39 about the situation."),
"the last paragraph of the section is present, so it is not capped: {context}"
);
assert!(
!context.contains("DECISIONWORD"),
"and the next section's prose is not: {context}"
);
assert!(
!context.contains("| **State** | Accepted |"),
"nor the preamble: {context}"
);
let decision = read("adr-0001-decision.md");
assert!(
decision.contains("DECISIONWORD paragraph 39 about the choice."),
"{decision}"
);
assert!(!decision.contains("CONTEXTWORD"), "{decision}");
assert!(
content_lines(&context) > 40,
"the section keeps its line structure, got {} line(s): {context}",
content_lines(&context)
);
let adr = read("adr-0001.md");
assert!(
adr.contains("| **State** | Accepted |"),
"the ADR note carries the span that belongs to no section: {adr}"
);
assert!(
!adr.contains("CONTEXTWORD") && !adr.contains("DECISIONWORD"),
"the ADR note does not restate its sections: {adr}"
);
let file = read("file-docs-adr-0001-example.md.md");
assert!(
file.contains("CONTEXTWORD paragraph 39 about the situation.")
&& file.contains("DECISIONWORD paragraph 39 about the choice."),
"the document note still holds all of it: {file}"
);
std::fs::remove_dir_all(&dir).ok();
}
fn content_lines(note: &str) -> usize {
let body = note.split_once("## Content\n\n").map_or("", |(_, r)| r);
let body = body.split_once("\n## ").map_or(body, |(head, _)| head);
body.trim_end().lines().count()
}
const CURRENT_PAGE: &str = "<marked current, not a link>";
fn bar_entries(html: &str) -> Vec<(String, String)> {
let Some(start) = html.find("<nav class=\"sitenav\">") else {
return Vec::new();
};
let bar = &html[start..];
let bar = &bar[..bar.find("</nav>").unwrap_or(bar.len())];
let mut entries = Vec::new();
let mut rest = bar;
while let Some(open) = rest.find('<') {
let Some((tag, after)) = rest[open + 1..].split_once('>') else {
break;
};
let label: String = after.chars().take_while(|c| *c != '<').collect();
let label = label.trim().to_owned();
if !label.is_empty() {
let dest = tag
.split_once("href=\"")
.and_then(|(_, r)| r.split_once('"'))
.map_or_else(|| CURRENT_PAGE.to_owned(), |(href, _)| href.to_owned());
entries.push((label, dest));
}
rest = after;
}
entries
}
fn bar_as_seen_from(html: &str, own_href: &str) -> Vec<(String, String)> {
let entries = bar_entries(html);
let marked = entries
.iter()
.filter(|(_, dest)| dest == CURRENT_PAGE)
.count();
assert_eq!(
marked, 1,
"the bar on {own_href} marks {marked} pages as current, expected exactly 1: {entries:?}"
);
entries
.into_iter()
.map(|(label, dest)| {
if dest == CURRENT_PAGE {
(label, own_href.to_owned())
} else {
(label, dest)
}
})
.collect()
}
#[test]
fn a_declared_site_page_is_emitted_with_the_shared_bar() {
let dir = fresh_dir("sitepage");
git(&dir, &["init", "-q"]);
write(&dir, "website/public/style.css", "body{color:#111}\n");
write(&dir, "website/public/index.html", "<h1>Home</h1>\n");
write(
&dir,
"docs/adr/0001-example.md",
"---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001: Example\n",
);
write(
&dir,
"website/pages/modes.md",
"---\nsite-page: modes\nsite-nav: Modes\nsite-order: 2\n---\n\n\
# The five ways to run it {#modes}\n\n## Offline mode\n\nNo models, no network.\n",
);
write(
&dir,
"docs/GUIDE.md",
"---\nsite-page: guide\nsite-nav: Guide\nsite-order: 1\n---\n\n\
# A guide\n\nSequenced in [the plan](BUILD_PLAN_V2.md).\n",
);
write(
&dir,
"docs/BUILD_PLAN_V2.md",
"---\nsite-page: build-plan-v2\nsite-nav: Roadmap\nsite-order: 3\n---\n\n# Roadmap\n",
);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
let out = Command::new(BIN)
.args(["render", "docs", "--out", "site"])
.current_dir(&dir)
.output()
.expect("run render");
assert!(out.status.success(), "render failed: {out:?}");
let site = dir.join("site");
let modes = std::fs::read_to_string(site.join("modes.html")).expect("modes page");
assert!(
site.join("guide.html").exists(),
"docs/GUIDE.md → guide.html"
);
assert!(site.join("build-plan-v2.html").exists());
assert!(
modes.contains("id=\"modes\""),
"explicit anchor preserved: {modes}"
);
assert!(!modes.contains("site-page"), "frontmatter is not content");
let expected: Vec<(String, String)> = [
("Home", "./"),
("Guide", "guide.html"),
("Modes", "modes.html"),
("Roadmap", "build-plan-v2.html"),
]
.into_iter()
.map(|(label, dest)| (label.to_owned(), dest.to_owned()))
.collect();
for page in ["modes.html", "guide.html", "build-plan-v2.html"] {
let html = std::fs::read_to_string(site.join(page)).expect("page");
assert_eq!(bar_as_seen_from(&html, page), expected, "bar on {page}");
}
assert!(
modes.contains("<span aria-current=\"page\">Modes</span>"),
"current page unlinked: {modes}"
);
let guide = std::fs::read_to_string(site.join("guide.html")).expect("guide page");
assert!(
guide.contains("href=\"build-plan-v2.html\""),
"link resolved to the published slug: {guide}"
);
assert!(
!guide.contains("BUILD_PLAN_V2.html"),
"the file-name guess is gone: {guide}"
);
}
#[test]
fn a_source_link_is_aimed_at_the_repository_at_the_rendered_commit() {
let dir = fresh_dir("sourcelink");
git(&dir, &["init", "-q"]);
write(&dir, "website/public/index.html", "<h1>Home</h1>\n");
write(
&dir,
"docs/adr/0001-example.md",
"---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001: Example\n\n\
Root config: [Cargo](../../Cargo.toml).\n",
);
write(
&dir,
"docs/BUILD_PLAN.md",
"# Build Plan\n\nEvidence: [sync](../crates/x/src/sync.rs).\n\n\
Site link: [adrs](adr/).\n",
);
write(&dir, "crates/x/src/sync.rs", "pub fn f() {}\n");
write(&dir, "Cargo.toml", "[workspace]\n");
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
git(&dir, &["remote", "add", "origin", "git@github.com:o/r.git"]);
let sha = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&dir)
.output()
.expect("rev-parse")
.stdout,
)
.expect("utf8");
let sha = sha.trim();
let out = Command::new(BIN)
.args(["render", "docs", "--out", "site"])
.current_dir(&dir)
.output()
.expect("run render");
assert!(out.status.success(), "render failed: {out:?}");
let plan = std::fs::read_to_string(dir.join("site/build-plan.html")).expect("plan page");
assert!(
plan.contains(&format!(
"href=\"https://github.com/o/r/blob/{sha}/crates/x/src/sync.rs\""
)),
"pinned to the rendered commit, not to a branch: {plan}"
);
assert!(plan.contains("href=\"adr/\""), "{plan}");
let adr = std::fs::read_to_string(dir.join("site/adr/0001-example.html")).expect("adr page");
assert!(
adr.contains(&format!(
"href=\"https://github.com/o/r/blob/{sha}/Cargo.toml\""
)),
"{adr}"
);
}
#[test]
fn without_an_origin_remote_a_source_link_is_left_as_authored() {
let dir = fresh_dir("noorigin");
git(&dir, &["init", "-q"]);
write(&dir, "website/public/index.html", "<h1>Home</h1>\n");
write(
&dir,
"docs/adr/0001-example.md",
"---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001: Example\n",
);
write(
&dir,
"docs/BUILD_PLAN.md",
"# Build Plan\n\nEvidence: [sync](../crates/x/src/sync.rs).\n",
);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
let out = Command::new(BIN)
.args(["render", "docs", "--out", "site"])
.current_dir(&dir)
.output()
.expect("run render");
assert!(out.status.success(), "render failed: {out:?}");
let plan = std::fs::read_to_string(dir.join("site/build-plan.html")).expect("plan page");
assert!(!plan.contains("github.com"), "nothing invented: {plan}");
assert!(
plan.contains("href=\"../crates/x/src/sync.rs\""),
"left exactly as authored: {plan}"
);
}
#[test]
fn the_bar_on_the_landing_page_is_rendered_over_whatever_the_file_carried() {
let dir = fresh_dir("landingnav");
git(&dir, &["init", "-q"]);
write(
&dir,
"website/public/index.html",
"<h1>Roteiro</h1>\n<nav class=\"sitenav\">\n<a href=\"gone.html\">Gone</a>\n</nav>\n\
<p>tail</p>\n",
);
write(
&dir,
"docs/adr/0001-example.md",
"---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001: Example\n",
);
write(
&dir,
"website/pages/modes.md",
"---\nsite-page: modes\nsite-nav: Modes\nsite-order: 1\n---\n\n# Modes\n",
);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
let out = Command::new(BIN)
.args(["render", "docs", "--out", "site"])
.current_dir(&dir)
.output()
.expect("run render");
assert!(out.status.success(), "render failed: {out:?}");
let landing = std::fs::read_to_string(dir.join("site/index.html")).expect("landing page");
assert!(
!landing.contains("gone.html"),
"the hand-written list is overwritten, not merged: {landing}"
);
assert_eq!(
bar_as_seen_from(&landing, "./"),
vec![
("Home".to_owned(), "./".to_owned()),
("Modes".to_owned(), "modes.html".to_owned()),
],
"the landing page carries the computed bar: {landing}"
);
assert!(landing.starts_with("<h1>Roteiro</h1>\n"), "{landing}");
assert!(landing.ends_with("<p>tail</p>\n"), "{landing}");
}
#[test]
fn the_landing_page_carries_the_bar_the_renderer_emits() {
let out_dir = std::env::temp_dir().join(format!("roteiro-website-bar-{}", std::process::id()));
std::fs::remove_dir_all(&out_dir).ok();
let out = Command::new(BIN)
.args(["render", "docs", "--out"])
.arg(&out_dir)
.current_dir(env!("CARGO_MANIFEST_DIR"))
.output()
.expect("run render");
assert!(out.status.success(), "render failed: {out:?}");
let landing = std::fs::read_to_string(out_dir.join("index.html")).expect("landing page");
let landing_bar = bar_as_seen_from(&landing, "./");
assert!(
!landing_bar.is_empty(),
"the landing page carries a site bar"
);
let rendered = std::fs::read_dir(&out_dir)
.expect("read site")
.filter_map(Result::ok)
.map(|e| e.path())
.find(|p| {
p.extension().and_then(|e| e.to_str()) == Some("html")
&& p.file_name().and_then(|n| n.to_str()) != Some("index.html")
&& std::fs::read_to_string(p).is_ok_and(|h| h.contains("<nav class=\"sitenav\">"))
})
.expect("at least one rendered site page");
let rendered_href = rendered
.file_name()
.and_then(|n| n.to_str())
.expect("rendered page name");
let emitted = bar_as_seen_from(
&std::fs::read_to_string(&rendered).expect("rendered page"),
rendered_href,
);
assert_eq!(
landing_bar,
emitted,
"website/public/index.html's site bar disagrees with the bar {} carries \
(labels *and* destinations are compared)",
rendered.display()
);
std::fs::remove_dir_all(&out_dir).ok();
}
fn workspace_fixture(tag: &str) -> (PathBuf, PathBuf) {
let base = fresh_dir(tag);
let home = base.join("home");
let app = base.join("app");
let deploy = base.join("deploy");
for d in [&home, &app, &deploy] {
std::fs::create_dir_all(d).expect("mkdir");
}
write(&app, "README.md", "# App\n\nThe hub.\n");
write(
&app,
"config.toml",
"[serve]\naddr = \"127.0.0.1:8017\"\ntools = true\n",
);
git(&app, &["init", "-q"]);
git(&app, &["add", "."]);
git(&app, &["commit", "-q", "-m", "init"]);
write(&deploy, "README.md", "# Deploy\n\nThe spoke.\n");
write(
&deploy,
"prod.env",
"SERVE_ADDR=0.0.0.0:8443\nSERVE_TOOLS=false\n",
);
git(&deploy, &["init", "-q"]);
git(&deploy, &["add", "."]);
git(&deploy, &["commit", "-q", "-m", "init"]);
std::fs::write(
home.join("config.toml"),
format!(
"[[workspaces]]\nname = \"prod\"\nrepos = [\"{}\", \"{}\"]\n",
app.display(),
deploy.display()
),
)
.expect("write config");
(base, home)
}
fn roteiro_in(dir: &Path, home: &Path, args: &[&str]) -> std::process::Output {
Command::new(BIN)
.args(args)
.current_dir(dir)
.env("ROTEIRO_HOME", home)
.output()
.expect("run roteiro")
}
#[test]
fn render_obsidian_workspace_spans_members_without_collision() {
let (base, home) = workspace_fixture("ws-span");
let vault = base.join("vault");
let out = roteiro_in(
&base,
&home,
&[
"render",
"obsidian",
"-w",
"prod",
"--out",
vault.to_str().unwrap(),
],
);
assert!(
out.status.success(),
"workspace render failed: {}",
String::from_utf8_lossy(&out.stderr)
);
for name in ["app-file-README.md.md", "deploy-file-README.md.md"] {
assert!(
vault.join(name).is_file(),
"missing {name}; vault holds: {:?}",
std::fs::read_dir(&vault)
.expect("read vault")
.filter_map(Result::ok)
.map(|e| e.file_name())
.collect::<Vec<_>>()
);
}
assert!(
!vault.join("file-README.md.md").is_file(),
"an unqualified note means one member overwrote the other"
);
let app_readme =
std::fs::read_to_string(vault.join("app-file-README.md.md")).expect("read note");
assert!(
app_readme.contains("project: \"app\""),
"a member note must say which member it is: {app_readme}"
);
assert!(app_readme.contains("- roteiro/project/app"), "{app_readme}");
let home_note = std::fs::read_to_string(vault.join("_Home.md")).expect("read _Home");
assert!(home_note.contains("# prod — workspace knowledge graph"));
assert!(home_note.contains("across **2** member repositories"));
assert!(home_note.contains("\n## app\n"), "{home_note}");
assert!(home_note.contains("\n## deploy\n"), "{home_note}");
assert_eq!(
home_note.matches("### Structure").count(),
2,
"each member keeps its own structure table: {home_note}"
);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn render_obsidian_workspace_follows_cross_repo_links_to_the_other_member() {
let (base, home) = workspace_fixture("ws-xrepo");
let vault = base.join("vault");
for member in ["app", "deploy"] {
let sync = roteiro_in(&base.join(member), &home, &["sync"]);
assert!(
sync.status.success(),
"{member} sync failed: {}",
String::from_utf8_lossy(&sync.stderr)
);
}
let infer = roteiro_in(
&base,
&home,
&[
"links",
"--infer",
"--hub",
"app",
"--write",
"--workspace-name",
"prod",
"--json",
],
);
assert!(
infer.status.success(),
"links --infer --write failed: {}",
String::from_utf8_lossy(&infer.stderr)
);
let report: serde_json::Value = serde_json::from_slice(&infer.stdout).expect("valid JSON");
assert_eq!(report["written"], 2, "two matches persisted: {report}");
let out = roteiro_in(
&base,
&home,
&[
"render",
"obsidian",
"-w",
"prod",
"--out",
vault.to_str().unwrap(),
],
);
assert!(
out.status.success(),
"workspace render failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let spoke = std::fs::read_to_string(vault.join("deploy-cfgkey-prod.env-SERVE_ADDR.md"))
.expect("read spoke config-key note");
assert!(
spoke.contains("[[app-cfgkey-config.toml-serve.addr]]"),
"the cross-repo edge must land on the hub's own note: {spoke}"
);
assert!(
!spoke.contains("extref"),
"and never on the local placeholder: {spoke}"
);
let stray: Vec<_> = std::fs::read_dir(&vault)
.expect("read vault")
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.contains("extref"))
.collect();
assert!(
stray.is_empty(),
"placeholder notes were written: {stray:?}"
);
let home_note = std::fs::read_to_string(vault.join("_Home.md")).expect("read _Home");
assert!(home_note.contains("## Cross-repo links"), "{home_note}");
assert!(
home_note
.contains("[[app-cfgkey-config.toml-serve.addr\\|app::cfgkey:config.toml#serve.addr]]"),
"{home_note}"
);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn render_obsidian_without_a_workspace_name_is_unchanged_inside_a_workspace() {
let (base, home) = workspace_fixture("ws-compat");
let app = base.join("app");
let vault = app.join("vault");
let out = roteiro_in(
&app,
&home,
&["render", "obsidian", "--out", vault.to_str().unwrap()],
);
assert!(
out.status.success(),
"project render failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
vault.join("file-README.md.md").is_file(),
"the bare note name must be what a project render writes"
);
assert!(
!vault.join("app-file-README.md.md").is_file(),
"a bare render must not qualify names: that renames every note and \
breaks every link a user wrote into the vault"
);
let note = std::fs::read_to_string(vault.join("file-README.md.md")).expect("read note");
assert!(!note.contains("project:"), "{note}");
assert!(!note.contains("roteiro/project/"), "{note}");
let home_note = std::fs::read_to_string(vault.join("_Home.md")).expect("read _Home");
assert!(home_note.contains("# app — knowledge graph"), "{home_note}");
assert!(
!home_note.contains("workspace knowledge graph"),
"{home_note}"
);
assert!(!home_note.contains("## Members"), "{home_note}");
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn render_obsidian_rejects_an_unknown_workspace_name() {
let (base, home) = workspace_fixture("ws-unknown");
let out = roteiro_in(
&base,
&home,
&["render", "obsidian", "-w", "prud", "--out", "vault"],
);
assert!(!out.status.success(), "an unknown name must fail");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("prud") && err.contains("prod"), "{err}");
std::fs::remove_dir_all(&base).ok();
}