use std::path::Path;
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 in {}", dir.display());
}
fn roteiro(dir: &Path, args: &[&str]) -> std::process::Output {
Command::new(BIN)
.args(args)
.current_dir(dir)
.output()
.expect("run roteiro")
}
fn head_sha(dir: &Path) -> String {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.expect("rev-parse");
assert!(
out.status.success(),
"git rev-parse HEAD failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).unwrap().trim().to_owned()
}
fn infer_json(base: &Path, extra: &[&str]) -> serde_json::Value {
let base_s = base.to_str().unwrap();
let mut args = vec!["links", "--infer", "--hub", "app", "--workspace", base_s];
args.extend_from_slice(extra);
args.push("--json");
let out = roteiro(base, &args);
assert!(out.status.success(), "infer {extra:?} failed: {out:?}");
serde_json::from_slice(&out.stdout).expect("JSON")
}
fn matched_hub_keys(report: &serde_json::Value) -> Vec<String> {
report["spokes"][0]["matches"]
.as_array()
.unwrap()
.iter()
.map(|m| m["hub_key"].as_str().unwrap().to_owned())
.collect()
}
#[test]
fn infer_resolves_against_a_pinned_hub_version() {
let base = std::env::temp_dir().join(format!("roteiro-hubrev-cli-{}", std::process::id()));
std::fs::remove_dir_all(&base).ok();
let app = base.join("app");
let deploy = base.join("deploy");
std::fs::create_dir_all(&app).expect("mkdir app");
std::fs::create_dir_all(&deploy).expect("mkdir deploy");
std::fs::write(app.join("config.toml"), "[serve]\ntools = true\n").expect("write");
git(&app, &["init", "-q"]);
git(&app, &["add", "."]);
git(&app, &["commit", "-q", "-m", "v1"]);
let v1 = head_sha(&app);
git(&app, &["tag", "rel-1"]);
assert!(roteiro(&app, &["sync"]).status.success(), "app v1 sync");
std::fs::write(app.join("config.toml"), "[serve]\nfeatures = true\n").expect("write");
git(&app, &["commit", "-aqm", "v2 rename"]);
assert!(roteiro(&app, &["sync"]).status.success(), "app v2 sync");
std::fs::write(deploy.join("prod.env"), "SERVE_TOOLS=true\n").expect("write");
git(&deploy, &["init", "-q"]);
git(&deploy, &["add", "."]);
git(&deploy, &["commit", "-q", "-m", "init"]);
assert!(roteiro(&deploy, &["sync"]).status.success(), "deploy sync");
let head = infer_json(&base, &[]);
let orphans: Vec<&str> = head["spokes"][0]["orphans"]
.as_array()
.unwrap()
.iter()
.map(|o| o["key"].as_str().unwrap())
.collect();
assert!(
orphans.contains(&"SERVE_TOOLS"),
"drift against HEAD: {head}"
);
assert_eq!(head["hub_rev"], serde_json::Value::Null);
let by_tag = infer_json(&base, &["--hub-rev", "rel-1"]);
assert_eq!(by_tag["hub_rev"], "rel-1", "reports the pinned rev (a tag)");
assert!(
matched_hub_keys(&by_tag).contains(&"serve.tools".to_owned()),
"resolves at the pinned version: {by_tag}"
);
assert!(
by_tag["spokes"][0]["orphans"]
.as_array()
.unwrap()
.is_empty(),
"no drift once resolved against the deployed version: {by_tag}"
);
let by_sha = infer_json(&base, &["--hub-rev", &v1]);
assert_eq!(by_sha["hub_rev"], v1);
assert!(
matched_hub_keys(&by_sha).contains(&"serve.tools".to_owned()),
"sha pin resolves too: {by_sha}"
);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn hub_rev_uses_a_published_graph_artifact_when_present() {
let base = std::env::temp_dir().join(format!("roteiro-artifact-{}", std::process::id()));
std::fs::remove_dir_all(&base).ok();
let app = base.join("app");
let deploy = base.join("deploy");
std::fs::create_dir_all(&app).expect("mkdir app");
std::fs::create_dir_all(&deploy).expect("mkdir deploy");
std::fs::write(app.join("config.toml"), "[serve]\ntools = true\n").expect("write");
git(&app, &["init", "-q"]);
git(&app, &["add", "."]);
git(&app, &["commit", "-q", "-m", "v1"]);
let v1 = head_sha(&app);
assert!(roteiro(&app, &["sync"]).status.success(), "app sync");
let export = roteiro(&app, &["export", "--out", "-"]);
assert!(export.status.success(), "export failed: {export:?}");
let mut art: serde_json::Value = serde_json::from_slice(&export.stdout).expect("artifact JSON");
art["facts"]["nodes"]
.as_array_mut()
.unwrap()
.push(serde_json::json!({
"key": "cfgkey:art.toml#artifact.only", "kind": "config_key", "name": "artifact.only",
"path": "art.toml", "lang": null, "blob_hash": null, "span": null,
"provenance": "derived", "meta": { "key": "artifact.only", "value": "yes" }
}));
let tree = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD^{tree}"])
.current_dir(&app)
.output()
.expect("tree")
.stdout,
)
.unwrap()
.trim()
.to_owned();
let art_dir = app.join(".git/roteiro/artifacts");
std::fs::create_dir_all(&art_dir).expect("mkdir artifacts");
std::fs::write(art_dir.join(format!("{tree}.json")), art.to_string()).expect("write artifact");
std::fs::write(
deploy.join("prod.env"),
"ARTIFACT_ONLY=yes\nSERVE_TOOLS=true\n",
)
.expect("write");
git(&deploy, &["init", "-q"]);
git(&deploy, &["add", "."]);
git(&deploy, &["commit", "-q", "-m", "init"]);
assert!(roteiro(&deploy, &["sync"]).status.success(), "deploy sync");
let base_s = base.to_str().unwrap();
let out = roteiro(
&base,
&[
"links",
"--infer",
"--hub",
"app",
"--hub-rev",
&v1,
"--workspace",
base_s,
"--json",
],
);
assert!(out.status.success(), "hub-rev infer failed: {out:?}");
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("JSON");
let matched = matched_hub_keys(&v);
assert!(
matched.contains(&"artifact.only".to_owned()),
"resolution used the published artifact (sentinel key present): {v}"
);
assert!(matched.contains(&"serve.tools".to_owned()), "{v}");
std::fs::write(art_dir.join(format!("{tree}.json")), "{ not valid json").expect("corrupt");
let v = infer_json(&base, &["--hub-rev", &v1]);
let matched = matched_hub_keys(&v);
assert!(
!matched.contains(&"artifact.only".to_owned()),
"corrupt artifact must be ignored: {v}"
);
assert!(
matched.contains(&"serve.tools".to_owned()),
"fell back to extraction: {v}"
);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn pinned_uses_pins_config_template_for_image_tags() {
let base = std::env::temp_dir().join(format!("roteiro-pinstmpl-{}", std::process::id()));
std::fs::remove_dir_all(&base).ok();
let app = base.join("app");
let deploy = base.join("deploy");
std::fs::create_dir_all(&app).expect("mkdir app");
std::fs::create_dir_all(&deploy).expect("mkdir deploy");
std::fs::write(app.join("config.toml"), "[serve]\ntools = true\n").expect("write");
git(&app, &["init", "-q"]);
git(&app, &["add", "."]);
git(&app, &["commit", "-q", "-m", "v1"]);
git(&app, &["tag", "release-1.2"]);
assert!(roteiro(&app, &["sync"]).status.success(), "app v1 sync");
std::fs::write(app.join("config.toml"), "[serve]\nfeatures = true\n").expect("write");
git(&app, &["commit", "-aqm", "v2"]);
assert!(roteiro(&app, &["sync"]).status.success(), "app v2 sync");
std::fs::write(deploy.join("Dockerfile"), "FROM registry.io/app:1.2\n").expect("write");
std::fs::write(deploy.join("prod.env"), "SERVE_TOOLS=true\n").expect("write");
std::fs::write(
deploy.join("roteiro.toml"),
"[pins]\napp = \"release-{tag}\"\n",
)
.expect("write");
git(&deploy, &["init", "-q"]);
git(&deploy, &["add", "."]);
git(&deploy, &["commit", "-q", "-m", "init"]);
assert!(roteiro(&deploy, &["sync"]).status.success(), "deploy sync");
let base_s = base.to_str().unwrap();
let out = roteiro(
&base,
&[
"links",
"--infer",
"--pinned",
"--hub",
"app",
"--workspace",
base_s,
"--json",
],
);
assert!(out.status.success(), "pinned infer failed: {out:?}");
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("JSON");
let spoke = &v["spokes"][0];
assert_eq!(
spoke["hub_rev"], "release-1.2",
"resolved via [pins] template: {v}"
);
assert!(
spoke["pin_via"].as_str().unwrap_or("").starts_with("image"),
"pinned via the image: {v}"
);
assert!(
matched_hub_keys(&v).contains(&"serve.tools".to_owned()),
"old key resolves at the deployed version: {v}"
);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn pinned_auto_resolves_each_spoke_against_the_version_it_vendors() {
let base = std::env::temp_dir().join(format!("roteiro-pinned-cli-{}", std::process::id()));
std::fs::remove_dir_all(&base).ok();
let app = base.join("app");
let deploy = base.join("deploy");
std::fs::create_dir_all(&app).expect("mkdir app");
std::fs::create_dir_all(&deploy).expect("mkdir deploy");
std::fs::write(app.join("config.toml"), "[serve]\ntools = true\n").expect("write");
git(&app, &["init", "-q"]);
git(&app, &["add", "."]);
git(&app, &["commit", "-q", "-m", "v1"]);
let v1 = head_sha(&app);
assert!(roteiro(&app, &["sync"]).status.success(), "app v1 sync");
std::fs::write(app.join("config.toml"), "[serve]\nfeatures = true\n").expect("write");
git(&app, &["commit", "-aqm", "v2"]);
assert!(roteiro(&app, &["sync"]).status.success(), "app v2 sync");
std::fs::write(deploy.join("prod.env"), "SERVE_TOOLS=true\n").expect("write");
std::fs::write(
deploy.join(".gitmodules"),
"[submodule \"app\"]\n\tpath = app\n\turl = https://github.com/acme/app.git\n",
)
.expect("write .gitmodules");
git(&deploy, &["init", "-q"]);
git(&deploy, &["add", "prod.env", ".gitmodules"]);
git(
&deploy,
&[
"update-index",
"--add",
"--cacheinfo",
&format!("160000,{v1},app"),
],
);
git(&deploy, &["commit", "-q", "-m", "deploy pinned to app@v1"]);
assert!(roteiro(&deploy, &["sync"]).status.success(), "deploy sync");
let base_s = base.to_str().unwrap();
let out = roteiro(
&base,
&[
"links",
"--infer",
"--pinned",
"--hub",
"app",
"--workspace",
base_s,
"--json",
],
);
assert!(out.status.success(), "pinned infer failed: {out:?}");
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("JSON");
let spoke = &v["spokes"][0];
assert_eq!(spoke["repo"], "deploy");
assert_eq!(
spoke["hub_rev"], v1,
"resolved against the vendored version: {v}"
);
assert!(
spoke["pin_via"]
.as_str()
.unwrap_or("")
.contains("submodule app"),
"reports the pin source: {v}"
);
let matched: Vec<&str> = spoke["matches"]
.as_array()
.unwrap()
.iter()
.map(|m| m["hub_key"].as_str().unwrap())
.collect();
assert!(
matched.contains(&"serve.tools"),
"resolves at the pinned version: {v}"
);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn links_resolve_across_repos_and_flag_drift() {
let base = std::env::temp_dir().join(format!("roteiro-links-cli-{}", std::process::id()));
std::fs::remove_dir_all(&base).ok();
let app = base.join("app");
let deploy = base.join("deploy");
std::fs::create_dir_all(&app).expect("mkdir app");
std::fs::create_dir_all(&deploy).expect("mkdir deploy");
std::fs::write(app.join("README.md"), "# App\n").expect("write");
git(&app, &["init", "-q"]);
git(&app, &["add", "."]);
git(&app, &["commit", "-q", "-m", "init"]);
assert!(roteiro(&app, &["sync"]).status.success(), "app sync failed");
std::fs::write(
deploy.join("roteiro.toml"),
"[[links]]\n\
to = \"app::file:README.md\"\n\
from = \"file:values.yaml\"\n\
kind = \"configures\"\n\
\n\
[[links]]\n\
to = \"app::file:gone.md\"\n\
\n\
[[links]]\n\
to = \"ghost::file:x\"\n",
)
.expect("write toml");
git(&deploy, &["init", "-q"]);
git(&deploy, &["add", "."]);
git(&deploy, &["commit", "-q", "-m", "init"]);
let base_s = base.to_str().unwrap();
let out = roteiro(&base, &["links", "--workspace", base_s]);
let text = String::from_utf8_lossy(&out.stdout);
assert!(!out.status.success(), "drift must fail the command: {text}");
assert!(
text.contains("app::file:README.md") && text.contains("ok"),
"{text}"
);
assert!(
text.contains("app::file:gone.md") && text.contains("DRIFT"),
"{text}"
);
let out = roteiro(&base, &["links", "--workspace", base_s, "--json"]);
let arr: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid JSON");
let arr = arr.as_array().expect("array");
assert_eq!(arr.len(), 3, "three declared links: {arr:?}");
let ok = arr.iter().filter(|r| r["status"] == "ok").count();
let drift = arr.iter().filter(|r| r["status"] == "drift").count();
assert_eq!((ok, drift), (1, 2), "one resolves, two drift: {arr:?}");
let resolved = arr.iter().find(|r| r["status"] == "ok").unwrap();
assert_eq!(resolved["to"], "app::file:README.md");
assert!(resolved["detail"].as_str().unwrap().contains("README.md"));
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn infer_matches_config_keys_across_repos_and_flags_orphans() {
let base = std::env::temp_dir().join(format!("roteiro-infer-cli-{}", std::process::id()));
std::fs::remove_dir_all(&base).ok();
let app = base.join("app");
let deploy = base.join("deploy");
std::fs::create_dir_all(&app).expect("mkdir app");
std::fs::create_dir_all(&deploy).expect("mkdir deploy");
std::fs::write(
app.join("config.toml"),
"[serve]\naddr = \"127.0.0.1:8017\"\ntools = true\n[models]\ngenerative = \"qwen3-0.6b\"\n",
)
.expect("write");
git(&app, &["init", "-q"]);
git(&app, &["add", "."]);
git(&app, &["commit", "-q", "-m", "init"]);
assert!(roteiro(&app, &["sync"]).status.success(), "app sync failed");
std::fs::write(
deploy.join("prod.env"),
"SERVE_ADDR=0.0.0.0:8443\nSERVE_TOOLS=false\nMAX_CONNECTIONS=512\n",
)
.expect("write");
git(&deploy, &["init", "-q"]);
git(&deploy, &["add", "."]);
git(&deploy, &["commit", "-q", "-m", "init"]);
assert!(
roteiro(&deploy, &["sync"]).status.success(),
"deploy sync failed"
);
let base_s = base.to_str().unwrap();
let out = roteiro(
&base,
&[
"links",
"--infer",
"--hub",
"app",
"--workspace",
base_s,
"--json",
],
);
assert!(
out.status.success(),
"infer is informational (exit 0): {out:?}"
);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid JSON");
assert_eq!(v["hub"], "app");
let spoke = &v["spokes"][0];
assert_eq!(spoke["repo"], "deploy");
let matched: Vec<&str> = spoke["matches"]
.as_array()
.unwrap()
.iter()
.map(|m| m["hub_key"].as_str().unwrap())
.collect();
assert!(matched.contains(&"serve.addr"), "{matched:?}");
assert!(matched.contains(&"serve.tools"), "{matched:?}");
let orphans: Vec<&str> = spoke["orphans"]
.as_array()
.unwrap()
.iter()
.map(|o| o["key"].as_str().unwrap())
.collect();
assert_eq!(
orphans,
vec!["MAX_CONNECTIONS"],
"the app-undefined key is the orphan"
);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn infer_write_persists_cross_repo_edges_that_survive_sync() {
let base = std::env::temp_dir().join(format!("roteiro-infer-write-{}", std::process::id()));
std::fs::remove_dir_all(&base).ok();
let app = base.join("app");
let deploy = base.join("deploy");
std::fs::create_dir_all(&app).expect("mkdir app");
std::fs::create_dir_all(&deploy).expect("mkdir deploy");
std::fs::write(
app.join("config.toml"),
"[serve]\naddr = \"127.0.0.1:8017\"\ntools = true\n",
)
.expect("write");
git(&app, &["init", "-q"]);
git(&app, &["add", "."]);
git(&app, &["commit", "-q", "-m", "init"]);
assert!(roteiro(&app, &["sync"]).status.success(), "app sync failed");
std::fs::write(
deploy.join("prod.env"),
"SERVE_ADDR=0.0.0.0:8443\nSERVE_TOOLS=false\n",
)
.expect("write");
git(&deploy, &["init", "-q"]);
git(&deploy, &["add", "."]);
git(&deploy, &["commit", "-q", "-m", "init"]);
assert!(
roteiro(&deploy, &["sync"]).status.success(),
"deploy sync failed"
);
let base_s = base.to_str().unwrap();
let out = roteiro(
&base,
&[
"links",
"--infer",
"--hub",
"app",
"--write",
"--workspace",
base_s,
"--json",
],
);
assert!(out.status.success(), "infer --write failed: {out:?}");
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid JSON");
assert_eq!(v["written"], 2, "two matches persisted as edges: {v}");
let q = roteiro(&deploy, &["query", "--kind", "external_ref", "--json"]);
assert!(q.status.success(), "query failed: {q:?}");
let text = String::from_utf8_lossy(&q.stdout);
assert!(
text.contains("app::cfgkey:config.toml#serve.addr"),
"external-ref to the hub key must be present: {text}"
);
assert!(
roteiro(&deploy, &["sync"]).status.success(),
"deploy re-sync failed"
);
let q = roteiro(&deploy, &["query", "--kind", "external_ref", "--json"]);
let text = String::from_utf8_lossy(&q.stdout);
assert!(
text.contains("app::cfgkey:config.toml#serve.addr"),
"external-ref must survive a re-sync: {text}"
);
std::fs::write(app.join("config.toml"), "[database]\nhost = \"db\"\n").expect("rewrite");
git(&app, &["commit", "-aqm", "unrelated config"]);
assert!(
roteiro(&app, &["sync"]).status.success(),
"app re-sync failed"
);
let out = roteiro(
&base,
&[
"links",
"--infer",
"--hub",
"app",
"--write",
"--workspace",
base_s,
"--json",
],
);
assert!(out.status.success(), "re-infer --write failed: {out:?}");
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid JSON");
assert_eq!(v["written"], 0, "no matches remain: {v}");
let q = roteiro(&deploy, &["query", "cfgkey:prod.env#SERVE_ADDR", "--json"]);
assert!(q.status.success(), "query failed: {q:?}");
let ex: serde_json::Value = serde_json::from_slice(&q.stdout).expect("valid JSON");
let outgoing = ex["outgoing"].as_array().cloned().unwrap_or_default();
assert!(
!outgoing
.iter()
.any(|e| e["node"].as_str().is_some_and(|n| n.starts_with("extref:"))),
"stale inferred cross-repo edge must be cleared when matches drop to zero: {ex}"
);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn incompatible_link_flag_combinations_are_rejected() {
for args in [
["links", "--infer", "--matrix"].as_slice(),
["links", "--write"].as_slice(), ["links", "--html"].as_slice(), ["links", "--out", "x.html"].as_slice(), ] {
let out = roteiro(Path::new("."), args);
assert!(
!out.status.success(),
"expected {args:?} to be rejected by clap"
);
}
}
#[test]
fn matrix_renders_override_grid_and_drift_across_formats() {
let base = std::env::temp_dir().join(format!("roteiro-matrix-cli-{}", std::process::id()));
std::fs::remove_dir_all(&base).ok();
let app = base.join("app");
let deploy = base.join("deploy");
std::fs::create_dir_all(&app).expect("mkdir app");
std::fs::create_dir_all(&deploy).expect("mkdir deploy");
std::fs::write(
app.join("config.toml"),
"[serve]\naddr = \"127.0.0.1:8017\"\ntools = true\n",
)
.expect("write");
git(&app, &["init", "-q"]);
git(&app, &["add", "."]);
git(&app, &["commit", "-q", "-m", "init"]);
assert!(roteiro(&app, &["sync"]).status.success(), "app sync failed");
std::fs::write(
deploy.join("prod.env"),
"SERVE_ADDR=0.0.0.0:8443\nSERVE_TOOLS=true\nMAX_CONNECTIONS=512\n",
)
.expect("write");
git(&deploy, &["init", "-q"]);
git(&deploy, &["add", "."]);
git(&deploy, &["commit", "-q", "-m", "init"]);
assert!(
roteiro(&deploy, &["sync"]).status.success(),
"deploy sync failed"
);
let base_s = base.to_str().unwrap();
let common = ["links", "--matrix", "--hub", "app", "--workspace", base_s];
let mut json_args = common.to_vec();
json_args.push("--json");
let out = roteiro(&base, &json_args);
assert!(out.status.success(), "matrix --json failed: {out:?}");
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid JSON");
assert_eq!(v["hub"], "app");
let rows = v["rows"].as_array().expect("rows");
let addr = rows
.iter()
.find(|r| r["hub_key"] == "serve.addr")
.expect("serve.addr row");
assert_eq!(
addr["cells"]["deploy"]["differs"], true,
"addr is an override"
);
let tools = rows
.iter()
.find(|r| r["hub_key"] == "serve.tools")
.expect("serve.tools row");
assert_eq!(
tools["cells"]["deploy"]["differs"], false,
"tools restated identically"
);
let drift: Vec<&str> = v["drift"]
.as_array()
.unwrap()
.iter()
.map(|d| d["key"].as_str().unwrap())
.collect();
assert_eq!(drift, vec!["MAX_CONNECTIONS"]);
let out = roteiro(&base, &common);
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("≠ deploy: 0.0.0.0:8443"), "{text}");
assert!(
text.contains("drift") && text.contains("MAX_CONNECTIONS"),
"{text}"
);
let html_path = base.join("overview.html");
let mut html_args = common.to_vec();
html_args.extend(["--html", "--out", html_path.to_str().unwrap()]);
let out = roteiro(&base, &html_args);
assert!(out.status.success(), "matrix --html failed: {out:?}");
let html = std::fs::read_to_string(&html_path).expect("html written");
assert!(html.starts_with("<!doctype html>"));
assert!(html.contains("<style>"), "self-contained CSS");
assert!(html.contains("serve.addr") && html.contains("0.0.0.0:8443"));
assert!(html.contains("MAX_CONNECTIONS"), "drift shown");
std::fs::remove_dir_all(&base).ok();
}