Skip to main content

automd_rs/parser/
readme.rs

1//! README block parsing and one-pass replacement for `<!-- automdrs:NAME ... -->` tags.
2
3use crate::error::Result;
4use crate::handler::{BlockHandler, UpdateContext};
5
6const OPEN_PREFIX: &str = "<!-- automdrs:";
7const OPEN_SUFFIX: &str = "-->";
8const CLOSE_TAG: &str = "<!-- /automdrs -->";
9
10/// A single automdrs block request parsed from README.
11#[derive(Debug, Clone)]
12pub struct BlockRequest {
13    pub name: String,
14    pub open_tag_line: String,
15}
16
17/// Parses block name from line like `<!-- automdrs:badges version -->` → `badges`.
18pub fn parse_block_name(line: &str) -> Option<&str> {
19    let t = line.trim();
20    if !t.starts_with(OPEN_PREFIX) || !t.ends_with(OPEN_SUFFIX) {
21        return None;
22    }
23    t.strip_prefix(OPEN_PREFIX)?
24        .strip_suffix(OPEN_SUFFIX)?
25        .trim()
26        .split_whitespace()
27        .next()
28}
29
30/// Collects all automdrs block requests in document order.
31pub fn parse_readme_blocks(content: &str) -> Vec<BlockRequest> {
32    let cap = content.matches(OPEN_PREFIX).count();
33    let mut requests = Vec::with_capacity(cap);
34    let mut in_block = false;
35    for line in content.lines() {
36        let t = line.trim();
37        if !in_block {
38            if let Some(name) = parse_block_name(line) {
39                in_block = true;
40                requests.push(BlockRequest {
41                    name: name.to_string(),
42                    open_tag_line: line.to_string(),
43                });
44            }
45            continue;
46        }
47        if t == CLOSE_TAG.trim() {
48            in_block = false;
49        }
50    }
51    requests
52}
53
54/// Single-pass: parse blocks, generate on the fly, replace. Avoids allocating BlockRequest and generated vecs.
55pub fn replace_blocks_with_handler(
56    content: &str,
57    handler: &dyn BlockHandler,
58    context: &UpdateContext,
59) -> Result<String> {
60    let mut out = String::with_capacity(content.len());
61    let mut in_block = false;
62    for line in content.lines() {
63        let t = line.trim();
64        if !in_block {
65            if let Some(name) = parse_block_name(line) {
66                in_block = true;
67                out.push_str(line);
68                out.push('\n');
69                let generated = handler.generate(name, line, context)?;
70                for s in &generated {
71                    out.push_str(s);
72                    out.push('\n');
73                }
74                continue;
75            }
76            out.push_str(line);
77            out.push('\n');
78            continue;
79        }
80        if t == CLOSE_TAG.trim() {
81            in_block = false;
82            out.push_str(line);
83            out.push('\n');
84        }
85        // else: skip block body lines (replaced by generated content)
86    }
87    if out.ends_with('\n') {
88        out.pop();
89    }
90    Ok(out)
91}
92
93/// Runs handler per request and returns generated lines in order.
94pub fn assign_and_generate(
95    requests: &[BlockRequest],
96    handler: &dyn BlockHandler,
97    context: &UpdateContext,
98) -> Result<Vec<Vec<String>>> {
99    let mut out = Vec::with_capacity(requests.len());
100    for req in requests {
101        out.push(handler.generate(&req.name, &req.open_tag_line, context)?);
102    }
103    Ok(out)
104}
105
106/// Replaces block bodies with `generated` in one pass. Output order matches block order.
107pub fn replace_blocks_once(content: &str, generated: &[Vec<String>]) -> String {
108    let extra: usize = generated
109        .iter()
110        .map(|v| v.iter().map(|s| s.len() + 1).sum::<usize>())
111        .sum();
112    let cap = content.len().saturating_add(extra);
113    let mut out = String::with_capacity(cap);
114    let mut in_block = false;
115    let mut idx = 0usize;
116    for line in content.lines() {
117        let t = line.trim();
118        if !in_block {
119            if parse_block_name(line).is_some() {
120                in_block = true;
121                out.push_str(line);
122                out.push('\n');
123                if idx < generated.len() {
124                    for s in &generated[idx] {
125                        out.push_str(s);
126                        out.push('\n');
127                    }
128                }
129                idx += 1;
130                continue;
131            }
132            out.push_str(line);
133            out.push('\n');
134            continue;
135        }
136        if t == CLOSE_TAG.trim() {
137            in_block = false;
138            out.push_str(line);
139            out.push('\n');
140        }
141    }
142    if out.ends_with('\n') {
143        out.pop();
144    }
145    out
146}
147
148pub fn update_readme(
149    content: &str,
150    handler: &dyn BlockHandler,
151    context: &UpdateContext,
152) -> Result<String> {
153    replace_blocks_with_handler(content, handler, context)
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_parse_block_name() {
162        assert_eq!(
163            parse_block_name("  <!-- automdrs:badges version -->  "),
164            Some("badges")
165        );
166        assert_eq!(
167            parse_block_name("<!-- automdrs:contributors -->"),
168            Some("contributors")
169        );
170        assert_eq!(parse_block_name("<!-- automdrs:foo a b -->"), Some("foo"));
171        assert_eq!(parse_block_name("<!-- other:tag -->"), None);
172        assert_eq!(parse_block_name("not a tag"), None);
173    }
174
175    #[test]
176    fn test_parse_readme_blocks_order() {
177        let content = "A\n<!-- automdrs:badges version -->\n<!-- /automdrs -->\nB\n<!-- automdrs:contributors -->\n<!-- /automdrs -->\n";
178        let reqs = parse_readme_blocks(content);
179        assert_eq!(reqs.len(), 2);
180        assert_eq!(reqs[0].name, "badges");
181        assert_eq!(reqs[1].name, "contributors");
182    }
183
184    #[test]
185    fn test_replace_blocks_once() {
186        let content = "Title\n\n<!-- automdrs:badges version -->\n<!-- /automdrs -->\n\nRest";
187        let generated = vec![vec!["line1".to_string(), "line2".to_string()]];
188        let out = replace_blocks_once(content, &generated);
189        assert!(out.contains("<!-- automdrs:badges version -->"));
190        assert!(out.contains("line1"));
191        assert!(out.contains("line2"));
192        assert!(out.contains("<!-- /automdrs -->"));
193        assert!(out.contains("Rest"));
194    }
195
196    #[test]
197    fn test_assign_and_generate() {
198        let requests = vec![
199            BlockRequest {
200                name: "badges".to_string(),
201                open_tag_line: "<!-- automdrs:badges version -->".to_string(),
202            },
203            BlockRequest {
204                name: "unknown".to_string(),
205                open_tag_line: "<!-- automdrs:unknown -->".to_string(),
206            },
207        ];
208        let ctx = crate::handler::UpdateContext::new(
209            crate::parser::cargo::ParsedManifest {
210                name: "x".to_string(),
211                description: "d".to_string(),
212                username: "u".to_string(),
213                repository_name: "r".to_string(),
214            },
215            std::path::PathBuf::from("."),
216        );
217        let handler = crate::handler::DefaultHandler::default();
218        let out = assign_and_generate(&requests, &handler, &ctx).unwrap();
219        assert_eq!(out.len(), 2);
220        assert!(!out[0].is_empty());
221        assert!(out[1].is_empty());
222    }
223
224    #[test]
225    fn test_update_readme() {
226        let content = "P\n<!-- automdrs:with-automdrs -->\n<!-- /automdrs -->\nQ";
227        let ctx = crate::handler::UpdateContext::new(
228            crate::parser::cargo::ParsedManifest {
229                name: "n".to_string(),
230                description: "d".to_string(),
231                username: "u".to_string(),
232                repository_name: "r".to_string(),
233            },
234            std::path::PathBuf::from("."),
235        );
236        let out = update_readme(content, &crate::handler::DefaultHandler::default(), &ctx).unwrap();
237        assert!(out.contains("automd-rs"));
238        assert!(out.contains("P"));
239        assert!(out.contains("Q"));
240    }
241}