use super::{error::ReadmeError, types::FeatureDoc};
pub(crate) fn render_readme(
template: &str,
version: &str,
rust_version: &str,
features: &[FeatureDoc],
snippet_group: usize,
conversions: &[String]
) -> Result<String, ReadmeError> {
let feature_bullets = render_feature_bullets(features);
let feature_snippet = render_feature_snippet(features, snippet_group);
let conversion_bullets = render_conversion_bullets(conversions);
let mut rendered = template.replace("{{CRATE_VERSION}}", version);
rendered = rendered.replace("{{MSRV}}", rust_version);
rendered = rendered.replace("{{FEATURE_BULLETS}}", &feature_bullets);
rendered = rendered.replace("{{FEATURE_SNIPPET}}", &feature_snippet);
rendered = rendered.replace("{{CONVERSION_BULLETS}}", &conversion_bullets);
if let Some(name) = find_placeholder(&rendered) {
return Err(ReadmeError::UnresolvedPlaceholder(name));
}
Ok(rendered)
}
pub(crate) fn render_feature_bullets(features: &[FeatureDoc]) -> String {
let mut lines = Vec::new();
for feature in features {
lines.push(format!("- `{}` — {}", feature.name, feature.description));
if !feature.extra.is_empty() {
for note in &feature.extra {
lines.push(format!(" - {note}"));
}
}
}
lines.join("\n")
}
pub(crate) fn render_conversion_bullets(conversions: &[String]) -> String {
conversions
.iter()
.map(|e| format!("- {e}"))
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn render_feature_snippet(features: &[FeatureDoc], group_size: usize) -> String {
if features.is_empty() {
return String::new();
}
let mut items = Vec::with_capacity(features.len());
for f in features {
items.push(format!("\"{}\"", f.name));
}
let chunk = group_size;
let chunks = items.len().div_ceil(chunk);
let mut lines = Vec::with_capacity(chunks);
for (i, part) in items.chunks(chunk).enumerate() {
let mut line = String::from("# ");
line.push_str(&part.join(", "));
if i + 1 != chunks {
line.push(',');
}
lines.push(line);
}
lines.join("\n")
}
pub(crate) fn find_placeholder(rendered: &str) -> Option<String> {
let start = rendered.find("{{")?;
let after = &rendered[start + 2..];
if let Some(end) = after.find("}}") {
let name = after[..end].trim();
Some(name.to_string())
} else {
let snippet: String = after.chars().take(32).collect();
Some(snippet)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_placeholder_detects_placeholder() {
let text = "Some text {{PLACEHOLDER}} more text";
let result = find_placeholder(text);
assert_eq!(result, Some("PLACEHOLDER".to_string()));
}
#[test]
fn find_placeholder_returns_none_when_no_placeholder() {
let text = "No placeholders here";
let result = find_placeholder(text);
assert_eq!(result, None);
}
#[test]
fn find_placeholder_handles_unclosed_braces() {
let text = "{{INCOMPLETE";
let result = find_placeholder(text);
assert!(result.is_some());
assert!(result.unwrap().starts_with("INCOMPLETE"));
}
#[test]
fn render_feature_bullets_creates_list() {
let features = vec![
FeatureDoc {
name: "actix".to_string(),
description: "Actix-web integration".to_string(),
extra: vec![]
},
FeatureDoc {
name: "axum".to_string(),
description: "Axum integration".to_string(),
extra: vec!["Requires Tokio runtime".to_string()]
},
];
let result = render_feature_bullets(&features);
assert!(result.contains("- `actix` — Actix-web integration"));
assert!(result.contains("- `axum` — Axum integration"));
assert!(result.contains(" - Requires Tokio runtime"));
}
#[test]
fn render_feature_bullets_handles_empty_list() {
let features = vec![];
let result = render_feature_bullets(&features);
assert_eq!(result, "");
}
#[test]
fn render_conversion_bullets_creates_list() {
let conversions = vec![
"std::io::Error → AppError::Internal".to_string(),
"String → AppError::BadRequest".to_string(),
];
let result = render_conversion_bullets(&conversions);
assert_eq!(
result,
"- std::io::Error → AppError::Internal\n- String → AppError::BadRequest"
);
}
#[test]
fn render_conversion_bullets_handles_empty_list() {
let conversions = vec![];
let result = render_conversion_bullets(&conversions);
assert_eq!(result, "");
}
#[test]
fn render_feature_snippet_groups_features() {
let features = vec![
FeatureDoc {
name: "feat1".to_string(),
description: "desc1".to_string(),
extra: vec![]
},
FeatureDoc {
name: "feat2".to_string(),
description: "desc2".to_string(),
extra: vec![]
},
FeatureDoc {
name: "feat3".to_string(),
description: "desc3".to_string(),
extra: vec![]
},
];
let result = render_feature_snippet(&features, 2);
assert!(result.contains("\"feat1\", \"feat2\","));
assert!(result.contains("\"feat3\""));
}
#[test]
fn render_feature_snippet_handles_empty_list() {
let features = vec![];
let result = render_feature_snippet(&features, 4);
assert_eq!(result, "");
}
#[test]
fn render_readme_substitutes_placeholders() {
let template = "Version: {{CRATE_VERSION}}\nMSRV: {{MSRV}}\nFeatures:\n{{FEATURE_BULLETS}}\nSnippet:\n{{FEATURE_SNIPPET}}\nConversions:\n{{CONVERSION_BULLETS}}";
let features = vec![FeatureDoc {
name: "test".to_string(),
description: "Test feature".to_string(),
extra: vec![]
}];
let conversions = vec!["Error → AppError".to_string()];
let result = render_readme(template, "1.0.0", "1.70", &features, 4, &conversions);
assert!(result.is_ok());
let rendered = result.unwrap();
assert!(rendered.contains("Version: 1.0.0"));
assert!(rendered.contains("MSRV: 1.70"));
assert!(rendered.contains("`test` — Test feature"));
assert!(rendered.contains("\"test\""));
assert!(rendered.contains("- Error → AppError"));
}
#[test]
fn render_readme_errors_on_unresolved_placeholder() {
let template = "{{CRATE_VERSION}} {{UNKNOWN}}";
let features = vec![];
let conversions = vec![];
let result = render_readme(template, "1.0.0", "1.70", &features, 4, &conversions);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ReadmeError::UnresolvedPlaceholder(_)
));
}
}