use std::path::Path;
use rmcp::handler::server::wrapper::Parameters;
use super::*;
fn extract_text(result: &CallToolResult) -> &str {
let content = result
.content
.first()
.unwrap_or_else(|| panic!("expected content"));
let ContentBlock::Text(text) = content else {
panic!("expected text content");
};
text.text.as_str()
}
fn extract_json(result: &CallToolResult) -> serde_json::Value {
if let Some(value) = result.structured_content.clone() {
return value;
}
serde_json::from_str(extract_text(result))
.unwrap_or_else(|e| panic!("invalid JSON result: {e}"))
}
fn create_stale_project(root: &Path) {
let template = "\
<!-- {@greeting} -->
Hello from mdt!
<!-- {/greeting} -->
";
let readme = "\
<!-- {=greeting} -->
Old stale content.
<!-- {/greeting} -->
";
std::fs::write(root.join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write template: {e}"));
std::fs::write(root.join("readme.md"), readme).unwrap_or_else(|e| panic!("write readme: {e}"));
}
fn create_synced_project(root: &Path) {
let template = "\
<!-- {@greeting} -->
Hello from mdt!
<!-- {/greeting} -->
";
let readme = "\
<!-- {=greeting} -->
Hello from mdt!
<!-- {/greeting} -->
";
std::fs::write(root.join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write template: {e}"));
std::fs::write(root.join("readme.md"), readme).unwrap_or_else(|e| panic!("write readme: {e}"));
}
fn create_formatter_only_stale_project(root: &Path) {
std::fs::write(
root.join("mdt.toml"),
r#"[[formatters]]
command = "/usr/bin/perl -0pe 's/Draft title/Published title/g'"
patterns = ["**/*.md"]
"#,
)
.unwrap_or_else(|e| panic!("write config: {e}"));
std::fs::write(
root.join("template.t.md"),
"<!-- {@body} -->\n\nBody content.\n\n<!-- {/body} -->\n",
)
.unwrap_or_else(|e| panic!("write template: {e}"));
std::fs::write(
root.join("readme.md"),
"# Draft title\n\n<!-- {=body} -->\n\nBody content.\n\n<!-- {/body} -->\n",
)
.unwrap_or_else(|e| panic!("write readme: {e}"));
}
fn create_multi_block_project(root: &Path) {
let template = "\
<!-- {@greeting} -->
Hello from mdt!
<!-- {/greeting} -->
<!-- {@farewell} -->
Goodbye from mdt!
<!-- {/farewell} -->
";
let readme = "\
<!-- {=greeting} -->
Hello from mdt!
<!-- {/greeting} -->
<!-- {=farewell} -->
Old farewell content.
<!-- {/farewell} -->
";
std::fs::write(root.join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write template: {e}"));
std::fs::write(root.join("readme.md"), readme).unwrap_or_else(|e| panic!("write readme: {e}"));
}
fn create_warning_project(root: &Path) {
std::fs::write(root.join("mdt.toml"), "[data]\npkg = \"package.json\"\n")
.unwrap_or_else(|e| panic!("write config: {e}"));
std::fs::write(
root.join("package.json"),
r#"{"name": "my-lib", "version": "1.0.0"}"#,
)
.unwrap_or_else(|e| panic!("write package: {e}"));
std::fs::write(
root.join("template.t.md"),
"<!-- {@install} -->\n\nnpm install {{ pkgg.name }}\n\n<!-- {/install} -->\n",
)
.unwrap_or_else(|e| panic!("write template: {e}"));
std::fs::write(
root.join("readme.md"),
"<!-- {=install} -->\n\nnpm install \n\n<!-- {/install} -->\n",
)
.unwrap_or_else(|e| panic!("write readme: {e}"));
}
#[test]
fn scan_ctx_on_empty_dir_succeeds() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let ctx = scan_ctx(tmp.path());
assert!(ctx.is_ok(), "scan_ctx should succeed on empty directory");
}
#[test]
fn scan_ctx_on_project_finds_providers() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_project(tmp.path());
let ctx = scan_ctx(tmp.path()).unwrap_or_else(|e| panic!("scan_ctx: {e}"));
assert!(
ctx.project.providers.contains_key("greeting"),
"should find the greeting provider"
);
}
#[test]
fn server_new_creates_instance() {
let _server = MdtMcpServer::new();
}
#[test]
fn server_default_creates_instance() {
let _server = MdtMcpServer::default();
}
#[tokio::test]
async fn init_creates_template_file() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let server = MdtMcpServer::new();
let result = server
.init(Parameters(InitParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("init: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["ok"], true);
assert_eq!(json["action"], "init");
assert_eq!(json["template_created"], true);
assert_eq!(json["config_created"], true);
assert!(
tmp.path().join(".templates/template.t.md").exists(),
".templates/template.t.md should exist"
);
assert!(
tmp.path().join("mdt.toml").exists(),
"mdt.toml should exist"
);
}
#[tokio::test]
async fn init_reports_existing_template() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
std::fs::create_dir_all(tmp.path().join(".templates")).unwrap_or_else(|e| panic!("mkdir: {e}"));
std::fs::write(
tmp.path().join(".templates/template.t.md"),
"existing content",
)
.unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.init(Parameters(InitParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("init: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["ok"], true);
assert_eq!(json["template_created"], false);
assert_eq!(json["config_created"], true);
assert!(
json["summary"]
.as_str()
.is_some_and(|summary| summary.contains("already exists")),
"expected 'already exists' summary, got: {json}"
);
assert!(
tmp.path().join("mdt.toml").exists(),
"mdt.toml should exist"
);
}
#[tokio::test]
async fn check_on_empty_project_reports_up_to_date() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let server = MdtMcpServer::new();
let result = server
.check(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("check: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["ok"], true);
assert_eq!(json["action"], "check");
assert_eq!(json["stale"], serde_json::json!([]));
assert_eq!(json["render_errors"], serde_json::json!([]));
}
#[tokio::test]
async fn check_on_synced_project_reports_up_to_date() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_synced_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.check(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("check: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["ok"], true);
assert_eq!(json["stale"], serde_json::json!([]));
assert_eq!(json["render_errors"], serde_json::json!([]));
}
#[tokio::test]
async fn check_on_stale_project_reports_stale_blocks() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.check(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("check: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["ok"], false);
assert_eq!(json["action"], "check");
assert_eq!(json["stale"][0]["block_name"], "greeting");
assert_eq!(json["stale"][0]["file"], "readme.md");
}
#[tokio::test]
async fn check_reports_formatter_only_stale_files() {
if cfg!(windows) {
return;
}
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_formatter_only_stale_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.check(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("check: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["ok"], false);
assert_eq!(json["stale"], serde_json::json!([]));
assert_eq!(json["stale_files"][0], "readme.md");
}
#[tokio::test]
async fn update_on_up_to_date_project_reports_no_changes() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_synced_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: false,
}))
.await
.unwrap_or_else(|e| panic!("update: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["ok"], true);
assert_eq!(json["action"], "update");
assert_eq!(json["updated_count"], 0);
assert_eq!(json["dry_run"], false);
}
#[tokio::test]
async fn update_on_stale_project_applies_changes() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: false,
}))
.await
.unwrap_or_else(|e| panic!("update: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["ok"], true);
assert_eq!(json["action"], "update");
assert_eq!(json["updated_count"], 1);
assert_eq!(json["updated_files"][0], "readme.md");
let readme_content = std::fs::read_to_string(tmp.path().join("readme.md"))
.unwrap_or_else(|e| panic!("read readme: {e}"));
assert!(
readme_content.contains("Hello from mdt!"),
"consumer should now have provider content"
);
assert!(
!readme_content.contains("Old stale content"),
"old stale content should be replaced"
);
}
#[tokio::test]
async fn update_formatter_only_stale_project_normalizes_file() {
if cfg!(windows) {
return;
}
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_formatter_only_stale_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: false,
}))
.await
.unwrap_or_else(|e| panic!("update: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["ok"], true);
assert_eq!(json["updated_count"], 0);
assert_eq!(json["updated_files"][0], "readme.md");
assert!(
json["summary"]
.as_str()
.unwrap_or_else(|| panic!("summary string"))
.contains("Normalized 1 file(s) via formatter integration.")
);
let readme_content = std::fs::read_to_string(tmp.path().join("readme.md"))
.unwrap_or_else(|e| panic!("read readme: {e}"));
assert!(readme_content.contains("Published title"));
}
#[tokio::test]
async fn update_dry_run_does_not_write() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: true,
}))
.await
.unwrap_or_else(|e| panic!("update: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["ok"], true);
assert_eq!(json["action"], "update");
assert_eq!(json["dry_run"], true);
assert_eq!(json["updated_count"], 1);
let readme_content = std::fs::read_to_string(tmp.path().join("readme.md"))
.unwrap_or_else(|e| panic!("read readme: {e}"));
assert!(
readme_content.contains("Old stale content"),
"dry run should not modify files"
);
}
#[tokio::test]
async fn update_dry_run_lists_affected_files() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: true,
}))
.await
.unwrap_or_else(|e| panic!("update: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["updated_files"][0], "readme.md");
}
#[tokio::test]
async fn update_includes_template_warnings_in_json() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_warning_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: true,
}))
.await
.unwrap_or_else(|e| panic!("update: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["warnings"][0]["block_name"], "install");
assert_eq!(json["warnings"][0]["undefined_variables"][0], "pkgg.name");
}
#[tokio::test]
async fn list_on_empty_project_returns_empty() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let server = MdtMcpServer::new();
let result = server
.list(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("list: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
assert_eq!(json["providers"], serde_json::json!([]));
assert_eq!(json["consumers"], serde_json::json!([]));
}
#[tokio::test]
async fn list_on_project_with_blocks_returns_provider_and_consumer() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.list(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("list: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
let providers = json["providers"]
.as_array()
.unwrap_or_else(|| panic!("providers should be array"));
assert_eq!(providers.len(), 1);
assert_eq!(providers[0]["name"], "greeting");
let consumers = json["consumers"]
.as_array()
.unwrap_or_else(|| panic!("consumers should be array"));
assert_eq!(consumers.len(), 1);
assert_eq!(consumers[0]["name"], "greeting");
assert_eq!(consumers[0]["is_stale"], true);
}
#[tokio::test]
async fn list_shows_synced_consumer_as_not_stale() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_synced_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.list(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("list: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
let consumers = json["consumers"]
.as_array()
.unwrap_or_else(|| panic!("consumers should be array"));
assert_eq!(consumers.len(), 1);
assert_eq!(consumers[0]["is_stale"], false);
}
#[tokio::test]
async fn list_with_multiple_blocks_returns_sorted_providers() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_multi_block_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.list(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("list: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
let providers = json["providers"]
.as_array()
.unwrap_or_else(|| panic!("providers should be array"));
assert_eq!(providers.len(), 2);
assert_eq!(providers[0]["name"], "farewell");
assert_eq!(providers[1]["name"], "greeting");
}
#[tokio::test]
async fn find_reuse_suggests_similar_blocks_and_file_types() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let template = "\
<!-- {@greeting} -->
Hello from mdt!
<!-- {/greeting} -->
<!-- {@goodbye} -->
Bye!
<!-- {/goodbye} -->
";
let readme = "\
<!-- {=greeting} -->
Old markdown content.
<!-- {/greeting} -->
";
let source = "\
//! <!-- {=greeting} -->
//!
//! Old source content.
//!
//! <!-- {/greeting} -->
";
std::fs::create_dir_all(tmp.path().join("src"))
.unwrap_or_else(|e| panic!("create src dir: {e}"));
std::fs::write(tmp.path().join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write template: {e}"));
std::fs::write(tmp.path().join("readme.md"), readme)
.unwrap_or_else(|e| panic!("write readme: {e}"));
std::fs::write(tmp.path().join("src/lib.rs"), source)
.unwrap_or_else(|e| panic!("write source: {e}"));
let server = MdtMcpServer::new();
let result = server
.find_reuse(Parameters(ReuseParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: Some("greting".to_string()),
limit: 5,
}))
.await
.unwrap_or_else(|e| panic!("find_reuse: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
assert!(
json["guidance"]
.as_str()
.is_some_and(|guidance| guidance.contains("reusing an existing provider")),
"expected reuse guidance, got: {text}"
);
let candidates = json["candidates"]
.as_array()
.unwrap_or_else(|| panic!("candidates should be array"));
assert!(!candidates.is_empty(), "expected at least one candidate");
assert_eq!(candidates[0]["name"], "greeting");
assert_eq!(candidates[0]["consumer_count"], 2);
assert!(
candidates[0]["markdown_files"]
.as_array()
.unwrap_or_else(|| panic!("markdown_files should be array"))
.iter()
.any(|value| value == "readme.md"),
"expected readme.md in markdown_files, got: {text}"
);
assert!(
candidates[0]["code_files"]
.as_array()
.unwrap_or_else(|| panic!("code_files should be array"))
.iter()
.any(|value| value == "src/lib.rs"),
"expected src/lib.rs in code_files, got: {text}"
);
}
#[tokio::test]
async fn find_reuse_sorts_by_consumer_count_without_query() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let template = "\
<!-- {@popular} -->
Popular block
<!-- {/popular} -->
<!-- {@rare} -->
Rare block
<!-- {/rare} -->
";
let readme = "\
<!-- {=popular} -->
Old content.
<!-- {/popular} -->
<!-- {=rare} -->
Old content.
<!-- {/rare} -->
";
let changelog = "\
<!-- {=popular} -->
Old changelog content.
<!-- {/popular} -->
";
std::fs::write(tmp.path().join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write template: {e}"));
std::fs::write(tmp.path().join("readme.md"), readme)
.unwrap_or_else(|e| panic!("write readme: {e}"));
std::fs::write(tmp.path().join("changelog.md"), changelog)
.unwrap_or_else(|e| panic!("write changelog: {e}"));
let server = MdtMcpServer::new();
let result = server
.find_reuse(Parameters(ReuseParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: None,
limit: 2,
}))
.await
.unwrap_or_else(|e| panic!("find_reuse: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
let candidates = json["candidates"]
.as_array()
.unwrap_or_else(|| panic!("candidates should be array"));
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0]["name"], "popular");
assert_eq!(candidates[0]["consumer_count"], 2);
assert_eq!(candidates[1]["name"], "rare");
assert_eq!(candidates[1]["consumer_count"], 1);
}
#[tokio::test]
async fn get_block_for_provider_returns_provider_info() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.get_block(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "greeting".to_string(),
}))
.await
.unwrap_or_else(|e| panic!("get_block: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
assert_eq!(json["type"], "provider");
assert_eq!(json["name"], "greeting");
assert_eq!(json["consumer_count"], 1);
let rendered = json["rendered_content"]
.as_str()
.unwrap_or_else(|| panic!("rendered_content should be string"));
assert!(
rendered.contains("Hello from mdt!"),
"rendered content should contain provider text"
);
}
#[tokio::test]
async fn get_block_for_provider_lists_consumer_files() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.get_block(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "greeting".to_string(),
}))
.await
.unwrap_or_else(|e| panic!("get_block: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
let consumer_files = json["consumer_files"]
.as_array()
.unwrap_or_else(|| panic!("consumer_files should be array"));
assert_eq!(consumer_files.len(), 1);
assert!(
consumer_files[0]
.as_str()
.unwrap_or_default()
.contains("readme.md"),
"consumer file should be readme.md"
);
}
#[tokio::test]
async fn get_block_for_consumer_only_returns_consumer_entries() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let readme = "\
<!-- {=orphan} -->
Some orphan content.
<!-- {/orphan} -->
";
std::fs::write(tmp.path().join("readme.md"), readme).unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(tmp.path().join("template.t.md"), "").unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.get_block(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "orphan".to_string(),
}))
.await
.unwrap_or_else(|e| panic!("get_block: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
let entries = json
.as_array()
.unwrap_or_else(|| panic!("expected array of consumer entries"));
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["type"], "consumer");
assert_eq!(entries[0]["name"], "orphan");
}
#[tokio::test]
async fn get_block_for_nonexistent_returns_error() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let server = MdtMcpServer::new();
let result = server
.get_block(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "nonexistent".to_string(),
}))
.await
.unwrap_or_else(|e| panic!("get_block: {e:?}"));
assert_eq!(
result.is_error,
Some(true),
"result should be marked as error"
);
let json = extract_json(&result);
assert_eq!(json["ok"], false);
assert!(
json["summary"]
.as_str()
.is_some_and(|summary| summary.contains("No block named")),
"expected 'No block named' message, got: {json}"
);
}
#[tokio::test]
async fn preview_for_existing_provider_returns_rendered_content() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.preview(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "greeting".to_string(),
}))
.await
.unwrap_or_else(|e| panic!("preview: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["ok"], true);
assert_eq!(json["action"], "preview");
assert_eq!(json["provider"]["name"], "greeting");
assert!(
json["provider"]["rendered_with_project_data"]
.as_str()
.is_some_and(|value| value.contains("Hello from mdt!")),
"expected rendered provider content, got: {json}"
);
}
#[tokio::test]
async fn preview_shows_consumer_info() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.preview(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "greeting".to_string(),
}))
.await
.unwrap_or_else(|e| panic!("preview: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["consumers"][0]["file"], "readme.md");
assert_eq!(json["consumers"][0]["is_stale"], true);
assert!(
json["consumers"][0]["rendered_content"]
.as_str()
.is_some_and(|value| value.contains("Hello from mdt!")),
"expected rendered consumer preview, got: {json}"
);
}
#[tokio::test]
async fn preview_for_nonexistent_provider_returns_error() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let server = MdtMcpServer::new();
let result = server
.preview(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "nonexistent".to_string(),
}))
.await
.unwrap_or_else(|e| panic!("preview: {e:?}"));
assert_eq!(
result.is_error,
Some(true),
"result should be marked as error"
);
let json = extract_json(&result);
assert_eq!(json["ok"], false);
assert!(
json["summary"]
.as_str()
.is_some_and(|summary| summary.contains("No provider named")),
"expected missing-provider error, got: {json}"
);
}
#[tokio::test]
async fn preview_provider_without_consumers_omits_consumer_section() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let template = "\
<!-- {@lonely} -->
Nobody references me.
<!-- {/lonely} -->
";
std::fs::write(tmp.path().join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.preview(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "lonely".to_string(),
}))
.await
.unwrap_or_else(|e| panic!("preview: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["provider"]["name"], "lonely");
assert_eq!(json["consumers"], serde_json::json!([]));
}
#[tokio::test]
async fn check_detects_missing_providers() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let template = "\
<!-- {@existing} -->
content
<!-- {/existing} -->
";
let readme = "\
<!-- {=missing_block} -->
placeholder
<!-- {/missing_block} -->
";
std::fs::write(tmp.path().join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write template: {e}"));
std::fs::write(tmp.path().join("readme.md"), readme)
.unwrap_or_else(|e| panic!("write readme: {e}"));
let server = MdtMcpServer::new();
let result = server
.check(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("check: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["ok"], false);
assert_eq!(json["missing_provider_names"][0], "missing_block");
}
#[tokio::test]
async fn check_includes_template_warnings_in_json() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_warning_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.check(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("check: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["warnings"][0]["block_name"], "install");
assert_eq!(json["warnings"][0]["undefined_variables"][0], "pkgg.name");
}
#[tokio::test]
async fn list_shows_correct_consumer_count() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_multi_block_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.list(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("list: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
let providers = json["providers"]
.as_array()
.unwrap_or_else(|| panic!("providers should be array"));
for provider in providers {
assert_eq!(
provider["consumer_count"], 1,
"each provider should have exactly one consumer"
);
}
}
#[tokio::test]
async fn list_includes_summary() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_multi_block_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.list(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("list: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
let summary = json["summary"]
.as_str()
.unwrap_or_else(|| panic!("summary should be string"));
assert!(
summary.contains("2 provider(s)"),
"expected 2 providers in summary, got: {summary}"
);
assert!(
summary.contains("2 consumer(s)"),
"expected 2 consumers in summary, got: {summary}"
);
}
#[tokio::test]
async fn update_fixes_multiple_stale_blocks() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_multi_block_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: false,
}))
.await
.unwrap_or_else(|e| panic!("update: {e:?}"));
let text = extract_text(&result);
assert!(
text.contains("Updated"),
"expected update confirmation, got: {text}"
);
let readme = std::fs::read_to_string(tmp.path().join("readme.md"))
.unwrap_or_else(|e| panic!("read readme: {e}"));
assert!(
readme.contains("Goodbye from mdt!"),
"farewell block should be updated"
);
assert!(
readme.contains("Hello from mdt!"),
"greeting block should remain"
);
}
#[tokio::test]
async fn init_creates_file_with_provider_block() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let server = MdtMcpServer::new();
server
.init(Parameters(InitParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("init: {e:?}"));
let content = std::fs::read_to_string(tmp.path().join(".templates/template.t.md"))
.unwrap_or_else(|e| panic!("read template: {e}"));
assert!(
content.contains("{@greeting}"),
"template should contain a provider block"
);
assert!(
content.contains("{/greeting}"),
"template should contain a closing tag"
);
}
#[test]
fn get_info_returns_server_info() {
let server = MdtMcpServer::new();
let info = server.get_info();
assert!(
info.instructions.is_some(),
"get_info should return instructions"
);
let instructions = info
.instructions
.unwrap_or_else(|| panic!("expected instructions"));
assert!(
instructions.contains("mdt"),
"instructions should mention mdt, got: {instructions}"
);
assert!(
instructions.contains("mdt_find_reuse"),
"instructions should encourage reuse discovery, got: {instructions}"
);
assert!(
info.capabilities.tools.is_some(),
"capabilities should have tools enabled"
);
}
#[tokio::test]
async fn check_reports_both_stale_and_missing() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let template = "\
<!-- {@greeting} -->
Hello from mdt!
<!-- {/greeting} -->
";
let readme = "\
<!-- {=greeting} -->
Old stale content.
<!-- {/greeting} -->
<!-- {=nonexistent} -->
placeholder
<!-- {/nonexistent} -->
";
std::fs::write(tmp.path().join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write template: {e}"));
std::fs::write(tmp.path().join("readme.md"), readme)
.unwrap_or_else(|e| panic!("write readme: {e}"));
let server = MdtMcpServer::new();
let result = server
.check(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("check: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["ok"], false);
assert_eq!(json["stale"][0]["block_name"], "greeting");
assert_eq!(json["missing_provider_names"][0], "nonexistent");
}
#[tokio::test]
async fn update_writes_files_and_reports_count() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_multi_block_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: false,
}))
.await
.unwrap_or_else(|e| panic!("update: {e:?}"));
let text = extract_text(&result);
assert!(
text.contains("Updated"),
"expected Updated message, got: {text}"
);
assert!(
text.contains("file(s)"),
"expected file count in message, got: {text}"
);
let result2 = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: false,
}))
.await
.unwrap_or_else(|e| panic!("update: {e:?}"));
let text2 = extract_text(&result2);
assert!(
text2.contains("already up to date"),
"expected no-changes message on second run, got: {text2}"
);
}
#[tokio::test]
async fn get_block_for_provider_shows_stale_consumers() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.get_block(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "greeting".to_string(),
}))
.await
.unwrap_or_else(|e| panic!("get_block: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
assert_eq!(json["type"], "provider");
assert_eq!(json["name"], "greeting");
assert_eq!(json["consumer_count"], 1);
let rendered = json["rendered_content"]
.as_str()
.unwrap_or_else(|| panic!("rendered_content should be string"));
assert!(
rendered.contains("Hello from mdt!"),
"expected provider text in rendered_content"
);
}
#[tokio::test]
async fn list_with_stale_and_synced_consumers() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_multi_block_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.list(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("list: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
let consumers = json["consumers"]
.as_array()
.unwrap_or_else(|| panic!("consumers should be array"));
let greeting_consumer = consumers
.iter()
.find(|c| c["name"] == "greeting")
.unwrap_or_else(|| panic!("expected greeting consumer"));
assert_eq!(
greeting_consumer["is_stale"], false,
"greeting should be synced"
);
let farewell_consumer = consumers
.iter()
.find(|c| c["name"] == "farewell")
.unwrap_or_else(|| panic!("expected farewell consumer"));
assert_eq!(
farewell_consumer["is_stale"], true,
"farewell should be stale"
);
}
#[tokio::test]
async fn check_with_template_data_interpolation() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
std::fs::write(
tmp.path().join("mdt.toml"),
"[data]\npkg = \"package.json\"\n",
)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(
tmp.path().join("package.json"),
r#"{"name": "my-tool", "version": "2.0.0"}"#,
)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(
tmp.path().join("template.t.md"),
"<!-- {@install} -->\n\nnpm install {{ pkg.name }}@{{ pkg.version }}\n\n<!-- {/install} \
-->\n",
)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(
tmp.path().join("readme.md"),
"<!-- {=install} -->\n\nnpm install my-tool@1.0.0\n\n<!-- {/install} -->\n",
)
.unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.check(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("check: {e:?}"));
let text = extract_text(&result);
assert!(
text.contains("stale"),
"expected stale message for outdated version, got: {text}"
);
}
#[tokio::test]
async fn update_with_data_interpolation_writes_rendered_content() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
std::fs::write(
tmp.path().join("mdt.toml"),
"[data]\npkg = \"package.json\"\n",
)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(
tmp.path().join("package.json"),
r#"{"name": "my-tool", "version": "2.0.0"}"#,
)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(
tmp.path().join("template.t.md"),
"<!-- {@install} -->\n\nnpm install {{ pkg.name }}@{{ pkg.version }}\n\n<!-- {/install} \
-->\n",
)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(
tmp.path().join("readme.md"),
"<!-- {=install} -->\n\nold\n\n<!-- {/install} -->\n",
)
.unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: false,
}))
.await
.unwrap_or_else(|e| panic!("update: {e:?}"));
let text = extract_text(&result);
assert!(
text.contains("Updated"),
"expected Updated message, got: {text}"
);
let readme_content = std::fs::read_to_string(tmp.path().join("readme.md"))
.unwrap_or_else(|e| panic!("read readme: {e}"));
assert!(
readme_content.contains("npm install my-tool@2.0.0"),
"readme should contain rendered template content, got: {readme_content}"
);
}
#[tokio::test]
async fn get_block_consumer_with_provider_and_data() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
std::fs::write(
tmp.path().join("mdt.toml"),
"[data]\npkg = \"package.json\"\n",
)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(tmp.path().join("package.json"), r#"{"version": "5.0.0"}"#)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(
tmp.path().join("template.t.md"),
"<!-- {@ver} -->\n\nv{{ pkg.version }}\n\n<!-- {/ver} -->\n",
)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(
tmp.path().join("readme.md"),
"<!-- {=ver} -->\n\nv4.0.0\n\n<!-- {/ver} -->\n",
)
.unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.get_block(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "ver".to_string(),
}))
.await
.unwrap_or_else(|e| panic!("get_block: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
assert_eq!(json["type"], "provider");
let rendered = json["rendered_content"]
.as_str()
.unwrap_or_else(|| panic!("rendered_content should be string"));
assert!(
rendered.contains("v5.0.0"),
"rendered content should contain interpolated version, got: {rendered}"
);
}
#[test]
fn scan_ctx_on_nonexistent_path_returns_default_context() {
let result = scan_ctx(Path::new("/tmp/nonexistent_mdt_test_dir_12345"));
if let Ok(ctx) = result {
assert!(
ctx.project.providers.is_empty(),
"nonexistent path should have no providers"
);
assert!(
ctx.project.consumers.is_empty(),
"nonexistent path should have no consumers"
);
} else {
}
}
#[tokio::test]
async fn check_project_providers_only_no_consumers() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let template = "\
<!-- {@greeting} -->
Hello from mdt!
<!-- {/greeting} -->
";
std::fs::write(tmp.path().join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write template: {e}"));
let server = MdtMcpServer::new();
let result = server
.check(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("check: {e:?}"));
let text = extract_text(&result);
assert!(
text.contains("up to date"),
"project with only providers should be up to date, got: {text}"
);
}
#[tokio::test]
async fn list_project_with_only_providers() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let template = "\
<!-- {@greeting} -->
Hello from mdt!
<!-- {/greeting} -->
";
std::fs::write(tmp.path().join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write template: {e}"));
let server = MdtMcpServer::new();
let result = server
.list(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("list: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
let providers = json["providers"]
.as_array()
.unwrap_or_else(|| panic!("providers should be array"));
assert_eq!(providers.len(), 1);
assert_eq!(providers[0]["name"], "greeting");
assert_eq!(providers[0]["consumer_count"], 0);
let consumers = json["consumers"]
.as_array()
.unwrap_or_else(|| panic!("consumers should be array"));
assert!(consumers.is_empty(), "should have no consumers");
}
#[tokio::test]
async fn list_project_with_only_consumers() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let readme = "\
<!-- {=orphan} -->
Some content.
<!-- {/orphan} -->
";
std::fs::write(tmp.path().join("readme.md"), readme)
.unwrap_or_else(|e| panic!("write readme: {e}"));
std::fs::write(tmp.path().join("template.t.md"), "")
.unwrap_or_else(|e| panic!("write template: {e}"));
let server = MdtMcpServer::new();
let result = server
.list(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("list: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
let providers = json["providers"]
.as_array()
.unwrap_or_else(|| panic!("providers should be array"));
assert!(providers.is_empty(), "should have no providers");
let consumers = json["consumers"]
.as_array()
.unwrap_or_else(|| panic!("consumers should be array"));
assert_eq!(consumers.len(), 1);
assert_eq!(consumers[0]["name"], "orphan");
assert_eq!(consumers[0]["is_stale"], false);
}
#[tokio::test]
async fn list_shows_consumer_transformers() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let template = "\
<!-- {@greeting} -->
Hello from mdt!
<!-- {/greeting} -->
";
let readme = "\
<!-- {=greeting|trim|indent:\" \"} -->
Hello from mdt!
<!-- {/greeting} -->
";
std::fs::write(tmp.path().join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write template: {e}"));
std::fs::write(tmp.path().join("readme.md"), readme)
.unwrap_or_else(|e| panic!("write readme: {e}"));
let server = MdtMcpServer::new();
let result = server
.list(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("list: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
let consumers = json["consumers"]
.as_array()
.unwrap_or_else(|| panic!("consumers should be array"));
assert_eq!(consumers.len(), 1);
let transformers = consumers[0]["transformers"]
.as_array()
.unwrap_or_else(|| panic!("transformers should be array"));
assert!(
transformers.iter().any(|t| t.as_str() == Some("trim")),
"expected trim transformer"
);
assert!(
transformers.iter().any(|t| t.as_str() == Some("indent")),
"expected indent transformer"
);
}
#[tokio::test]
async fn update_dry_run_reports_block_and_file_count() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_multi_block_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: true,
}))
.await
.unwrap_or_else(|e| panic!("update: {e:?}"));
let text = extract_text(&result);
assert!(
text.contains("Dry run"),
"expected dry run prefix, got: {text}"
);
assert!(
text.contains("block(s)"),
"expected block count in dry run output, got: {text}"
);
assert!(
text.contains("file(s)"),
"expected file count in dry run output, got: {text}"
);
}
#[tokio::test]
async fn update_empty_project_reports_no_changes() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let server = MdtMcpServer::new();
let result = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: false,
}))
.await
.unwrap_or_else(|e| panic!("update: {e:?}"));
let text = extract_text(&result);
assert!(
text.contains("already up to date"),
"empty project should report no changes, got: {text}"
);
}
#[tokio::test]
async fn update_dry_run_synced_project_no_changes() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_synced_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: true,
}))
.await
.unwrap_or_else(|e| panic!("update: {e:?}"));
let text = extract_text(&result);
assert!(
text.contains("already up to date"),
"synced project dry run should report no changes, got: {text}"
);
}
#[tokio::test]
async fn get_block_provider_with_no_consumers() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let template = "\
<!-- {@lonely} -->
Nobody references me.
<!-- {/lonely} -->
";
std::fs::write(tmp.path().join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.get_block(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "lonely".to_string(),
}))
.await
.unwrap_or_else(|e| panic!("get_block: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
assert_eq!(json["type"], "provider");
assert_eq!(json["name"], "lonely");
assert_eq!(json["consumer_count"], 0);
let consumer_files = json["consumer_files"]
.as_array()
.unwrap_or_else(|| panic!("consumer_files should be array"));
assert!(consumer_files.is_empty(), "should have no consumer files");
}
#[tokio::test]
async fn get_block_provider_raw_vs_rendered_content() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
std::fs::write(
tmp.path().join("mdt.toml"),
"[data]\npkg = \"package.json\"\n",
)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(
tmp.path().join("package.json"),
r#"{"name": "my-lib", "version": "3.0.0"}"#,
)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(
tmp.path().join("template.t.md"),
"<!-- {@install} -->\n\nnpm install {{ pkg.name }}@{{ pkg.version }}\n\n<!-- {/install} \
-->\n",
)
.unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.get_block(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "install".to_string(),
}))
.await
.unwrap_or_else(|e| panic!("get_block: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
let raw = json["raw_content"]
.as_str()
.unwrap_or_else(|| panic!("raw_content should be string"));
let rendered = json["rendered_content"]
.as_str()
.unwrap_or_else(|| panic!("rendered_content should be string"));
assert!(
raw.contains("{{ pkg.name }}"),
"raw_content should contain template syntax, got: {raw}"
);
assert!(
rendered.contains("npm install my-lib@3.0.0"),
"rendered_content should contain interpolated values, got: {rendered}"
);
}
#[tokio::test]
async fn preview_with_data_interpolation() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
std::fs::write(
tmp.path().join("mdt.toml"),
"[data]\npkg = \"package.json\"\n",
)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(tmp.path().join("package.json"), r#"{"version": "7.0.0"}"#)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(
tmp.path().join("template.t.md"),
"<!-- {@ver} -->\n\nv{{ pkg.version }}\n\n<!-- {/ver} -->\n",
)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(
tmp.path().join("readme.md"),
"<!-- {=ver} -->\n\nv6.0.0\n\n<!-- {/ver} -->\n",
)
.unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.preview(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "ver".to_string(),
}))
.await
.unwrap_or_else(|e| panic!("preview: {e:?}"));
let text = extract_text(&result);
assert!(
text.contains("v7.0.0"),
"preview should show rendered content with interpolation, got: {text}"
);
assert!(
text.contains("consumer(s)"),
"preview should show consumer section, got: {text}"
);
}
#[tokio::test]
async fn preview_shows_transformed_consumer_content() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let template = "\
<!-- {@greeting} -->
Hello from mdt!
<!-- {/greeting} -->
";
let readme = "\
<!-- {=greeting|trim} -->
Hello from mdt!
<!-- {/greeting} -->
";
std::fs::write(tmp.path().join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(tmp.path().join("readme.md"), readme).unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.preview(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "greeting".to_string(),
}))
.await
.unwrap_or_else(|e| panic!("preview: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["consumers"][0]["transformers"][0], "trim");
}
#[tokio::test]
async fn check_stale_project_reports_block_name_and_file() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.check(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("check: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["stale"][0]["block_name"], "greeting");
assert_eq!(json["stale"][0]["file"], "readme.md");
assert!(
json["summary"]
.as_str()
.is_some_and(|summary| summary.contains("stale consumer block")),
"check output should include summary, got: {json}"
);
}
#[tokio::test]
async fn update_is_idempotent() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_project(tmp.path());
let server = MdtMcpServer::new();
let result1 = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: false,
}))
.await
.unwrap_or_else(|e| panic!("update 1: {e:?}"));
assert!(
extract_text(&result1).contains("Updated"),
"first update should make changes"
);
let content_after_first = std::fs::read_to_string(tmp.path().join("readme.md"))
.unwrap_or_else(|e| panic!("read: {e}"));
let result2 = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: false,
}))
.await
.unwrap_or_else(|e| panic!("update 2: {e:?}"));
assert!(
extract_text(&result2).contains("already up to date"),
"second update should be no-op"
);
let content_after_second = std::fs::read_to_string(tmp.path().join("readme.md"))
.unwrap_or_else(|e| panic!("read: {e}"));
assert_eq!(
content_after_first, content_after_second,
"content should be unchanged after idempotent update"
);
}
#[tokio::test]
async fn init_in_nested_directory() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let nested = tmp.path().join("a").join("b").join("c");
std::fs::create_dir_all(&nested).unwrap_or_else(|e| panic!("mkdir: {e}"));
let server = MdtMcpServer::new();
let result = server
.init(Parameters(InitParam {
path: Some(nested.to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("init: {e:?}"));
let text = extract_text(&result);
assert!(
text.contains("Created template file"),
"expected creation message, got: {text}"
);
assert!(
nested.join(".templates/template.t.md").exists(),
"template should be created in nested dir"
);
assert!(
nested.join("mdt.toml").exists(),
"mdt.toml should be created"
);
}
#[tokio::test]
async fn check_multiple_stale_blocks_reports_count() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_multi_block_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.check(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("check: {e:?}"));
let text = extract_text(&result);
assert!(
text.contains("stale"),
"expected stale blocks in output, got: {text}"
);
assert!(
text.contains("farewell"),
"expected farewell block name, got: {text}"
);
}
#[tokio::test]
async fn get_block_provider_with_multiple_consumers() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let template = "\
<!-- {@greeting} -->
Hello from mdt!
<!-- {/greeting} -->
";
let readme = "\
<!-- {=greeting} -->
Old content 1.
<!-- {/greeting} -->
";
let docs = "\
<!-- {=greeting} -->
Old content 2.
<!-- {/greeting} -->
";
std::fs::write(tmp.path().join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(tmp.path().join("readme.md"), readme).unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(tmp.path().join("docs.md"), docs).unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.get_block(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "greeting".to_string(),
}))
.await
.unwrap_or_else(|e| panic!("get_block: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
assert_eq!(json["type"], "provider");
assert_eq!(json["consumer_count"], 2);
let consumer_files = json["consumer_files"]
.as_array()
.unwrap_or_else(|| panic!("consumer_files should be array"));
assert_eq!(consumer_files.len(), 2);
}
#[tokio::test]
async fn update_multiple_files() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let template = "\
<!-- {@greeting} -->
Hello from mdt!
<!-- {/greeting} -->
";
let readme = "\
<!-- {=greeting} -->
Old readme content.
<!-- {/greeting} -->
";
let docs = "\
<!-- {=greeting} -->
Old docs content.
<!-- {/greeting} -->
";
std::fs::write(tmp.path().join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(tmp.path().join("readme.md"), readme).unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(tmp.path().join("docs.md"), docs).unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: false,
}))
.await
.unwrap_or_else(|e| panic!("update: {e:?}"));
let text = extract_text(&result);
assert!(text.contains("Updated"), "expected update, got: {text}");
assert!(
text.contains("2 file(s)"),
"expected 2 files updated, got: {text}"
);
let readme_content = std::fs::read_to_string(tmp.path().join("readme.md"))
.unwrap_or_else(|e| panic!("read: {e}"));
let docs_content =
std::fs::read_to_string(tmp.path().join("docs.md")).unwrap_or_else(|e| panic!("read: {e}"));
assert!(
readme_content.contains("Hello from mdt!"),
"readme should be updated"
);
assert!(
docs_content.contains("Hello from mdt!"),
"docs should be updated"
);
}
#[tokio::test]
async fn list_summary_format() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.list(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("list: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
let summary = json["summary"]
.as_str()
.unwrap_or_else(|| panic!("summary should be string"));
assert!(
summary.contains("1 provider(s)"),
"expected '1 provider(s)' in summary, got: {summary}"
);
assert!(
summary.contains("1 consumer(s)"),
"expected '1 consumer(s)' in summary, got: {summary}"
);
}
#[tokio::test]
async fn list_provider_content_is_trimmed() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.list(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("list: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
let providers = json["providers"]
.as_array()
.unwrap_or_else(|| panic!("providers should be array"));
let content = providers[0]["content"]
.as_str()
.unwrap_or_else(|| panic!("content should be string"));
assert_eq!(
content,
content.trim(),
"provider content should be trimmed in list output"
);
}
#[tokio::test]
async fn list_uses_relative_file_paths() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.list(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("list: {e:?}"));
let text = extract_text(&result);
let json: serde_json::Value =
serde_json::from_str(text).unwrap_or_else(|e| panic!("invalid JSON: {e}"));
let providers = json["providers"]
.as_array()
.unwrap_or_else(|| panic!("providers should be array"));
let file = providers[0]["file"]
.as_str()
.unwrap_or_else(|| panic!("file should be string"));
assert_eq!(
file, "template.t.md",
"provider file path should be relative"
);
let consumers = json["consumers"]
.as_array()
.unwrap_or_else(|| panic!("consumers should be array"));
let consumer_file = consumers[0]["file"]
.as_str()
.unwrap_or_else(|| panic!("file should be string"));
assert_eq!(
consumer_file, "readme.md",
"consumer file path should be relative"
);
}
#[tokio::test]
async fn preview_with_multiple_consumers() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
let template = "\
<!-- {@greeting} -->
Hello from mdt!
<!-- {/greeting} -->
";
let readme = "\
<!-- {=greeting} -->
Old readme.
<!-- {/greeting} -->
";
let docs = "\
<!-- {=greeting|trim} -->
Hello from mdt!
<!-- {/greeting} -->
";
std::fs::write(tmp.path().join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(tmp.path().join("readme.md"), readme).unwrap_or_else(|e| panic!("write: {e}"));
std::fs::write(tmp.path().join("docs.md"), docs).unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.preview(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "greeting".to_string(),
}))
.await
.unwrap_or_else(|e| panic!("preview: {e:?}"));
let text = extract_text(&result);
assert!(
text.contains("2 consumer(s)"),
"expected 2 consumers, got: {text}"
);
assert!(
text.contains("readme.md"),
"expected readme.md in output, got: {text}"
);
assert!(
text.contains("docs.md"),
"expected docs.md in output, got: {text}"
);
}
#[tokio::test]
async fn check_with_invalid_config_handles_gracefully() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
std::fs::write(tmp.path().join("mdt.toml"), "[data\nbad toml")
.unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.check(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await;
assert!(
result.is_err(),
"check with invalid config should return an error"
);
}
#[tokio::test]
async fn update_with_invalid_config_handles_gracefully() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
std::fs::write(tmp.path().join("mdt.toml"), "[data\nbad toml")
.unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: false,
}))
.await;
assert!(
result.is_err(),
"update with invalid config should return an error"
);
}
#[tokio::test]
async fn list_with_invalid_config_handles_gracefully() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
std::fs::write(tmp.path().join("mdt.toml"), "[data\nbad toml")
.unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.list(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await;
assert!(
result.is_err(),
"list with invalid config should return an error"
);
}
#[tokio::test]
async fn get_block_with_invalid_config_handles_gracefully() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
std::fs::write(tmp.path().join("mdt.toml"), "[data\nbad toml")
.unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.get_block(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "test".to_string(),
}))
.await;
assert!(
result.is_err(),
"get_block with invalid config should return an error"
);
}
#[tokio::test]
async fn preview_with_invalid_config_handles_gracefully() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
std::fs::write(tmp.path().join("mdt.toml"), "[data\nbad toml")
.unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.preview(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "test".to_string(),
}))
.await;
assert!(
result.is_err(),
"preview with invalid config should return an error"
);
}
#[tokio::test]
async fn check_with_missing_data_file() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
std::fs::write(
tmp.path().join("mdt.toml"),
"[data]\npkg = \"nonexistent.json\"\n",
)
.unwrap_or_else(|e| panic!("write: {e}"));
let template = "<!-- {@ver} -->\n\nv{{ pkg.version }}\n\n<!-- {/ver} -->\n";
std::fs::write(tmp.path().join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write: {e}"));
let server = MdtMcpServer::new();
let result = server
.check(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await;
assert!(
result.is_ok() || result.is_err(),
"should handle missing data file without panicking"
);
}
fn create_stale_args_project(root: &Path) {
let template = "\
<!-- {@badges:\"crate_name\"} -->
[]
<!-- {/badges} -->
";
let readme = "\
<!-- {=badges:\"mdt_core\"} -->
Old stale content.
<!-- {/badges} -->
";
std::fs::write(root.join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write template: {e}"));
std::fs::write(root.join("readme.md"), readme).unwrap_or_else(|e| panic!("write readme: {e}"));
}
fn create_args_mismatch_project(root: &Path) {
let template = "\
<!-- {@badges:\"crate_name\"} -->
Content for {{ crate_name }}.
<!-- {/badges} -->
";
let readme = "\
<!-- {=badges:\"mdt_core\":\"extra\"} -->
Old content.
<!-- {/badges} -->
";
std::fs::write(root.join("template.t.md"), template)
.unwrap_or_else(|e| panic!("write template: {e}"));
std::fs::write(root.join("readme.md"), readme).unwrap_or_else(|e| panic!("write readme: {e}"));
}
#[tokio::test]
async fn check_with_block_arguments_detects_stale() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_args_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.check(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("check: {e:?}"));
let text = extract_text(&result);
assert!(
text.contains("stale"),
"expected stale message, got: {text}"
);
assert!(
text.contains("badges"),
"expected block name in message, got: {text}"
);
}
#[tokio::test]
async fn check_reports_argument_count_mismatch() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_args_mismatch_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.check(Parameters(PathParam {
path: Some(tmp.path().to_string_lossy().to_string()),
}))
.await
.unwrap_or_else(|e| panic!("check: {e:?}"));
let text = extract_text(&result);
assert!(
text.contains("argument count mismatch"),
"expected argument count mismatch message, got: {text}"
);
}
#[tokio::test]
async fn update_with_block_arguments_applies_changes() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_args_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.update(Parameters(UpdateParam {
path: Some(tmp.path().to_string_lossy().to_string()),
dry_run: false,
}))
.await
.unwrap_or_else(|e| panic!("update: {e:?}"));
let text = extract_text(&result);
assert!(
text.contains("Updated"),
"expected update confirmation, got: {text}"
);
let readme_content = std::fs::read_to_string(tmp.path().join("readme.md"))
.unwrap_or_else(|e| panic!("read readme: {e}"));
assert!(
readme_content.contains("mdt_core"),
"consumer should contain rendered argument value 'mdt_core'"
);
assert!(
!readme_content.contains("{{ crate_name }}"),
"template variable should be interpolated"
);
assert!(
!readme_content.contains("Old stale content"),
"old stale content should be replaced"
);
}
#[tokio::test]
async fn preview_with_block_arguments_shows_provider_template() {
let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
create_stale_args_project(tmp.path());
let server = MdtMcpServer::new();
let result = server
.preview(Parameters(BlockParam {
path: Some(tmp.path().to_string_lossy().to_string()),
block_name: "badges".to_string(),
}))
.await
.unwrap_or_else(|e| panic!("preview: {e:?}"));
let json = extract_json(&result);
assert_eq!(json["provider"]["name"], "badges");
assert_eq!(json["consumers"][0]["file"], "readme.md");
assert!(
json["consumers"][0]["rendered_content"]
.as_str()
.is_some_and(|value| value.contains("mdt_core")),
"expected argument-aware consumer preview, got: {json}"
);
}