verbs/
doctor_docs_plan.rs1use std::path::Path;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct DocsInvocation {
13 pub line: usize,
15 pub raw: String,
17 pub tokens: Vec<String>,
19}
20
21pub fn extract_invocations(text: &str) -> Vec<DocsInvocation> {
24 let mut result = Vec::new();
25 let mut in_fence = false;
26 let mut planned_fence = false;
27 let mut skip_next_planned_line = false;
28 for (idx, line) in text.lines().enumerate() {
29 let line_no = idx + 1;
30 let trimmed = line.trim_start();
31 if trimmed.starts_with("```") {
32 if in_fence {
33 in_fence = false;
34 planned_fence = false;
35 } else {
36 in_fence = true;
37 planned_fence = is_planned_docs_marker(trimmed) || skip_next_planned_line;
38 skip_next_planned_line = false;
39 }
40 continue;
41 }
42 if is_planned_docs_marker(line) {
43 skip_next_planned_line = true;
44 continue;
45 }
46 if skip_next_planned_line {
47 if trimmed.is_empty() {
48 continue;
49 }
50 skip_next_planned_line = false;
51 continue;
52 }
53 if planned_fence {
54 continue;
55 }
56 if in_fence {
57 let lower = line.trim_start();
60 let cleaned = strip_shell_prefix(lower);
62 if let Some(rest) = cleaned.strip_prefix("heddle ")
63 && let Some(tokens) = tokenize(rest)
64 {
65 result.push(DocsInvocation {
66 line: line_no,
67 raw: format!("heddle {}", rest.trim_end()),
68 tokens,
69 });
70 }
71 } else {
72 let bytes = line.as_bytes();
75 let mut i = 0;
76 while i < bytes.len() {
77 if bytes[i] == b'`' {
78 let start = i + 1;
79 let mut end = start;
80 while end < bytes.len() && bytes[end] != b'`' {
81 end += 1;
82 }
83 if end <= bytes.len() {
84 let snippet = &line[start..end];
85 let cleaned = strip_shell_prefix(snippet);
86 if let Some(rest) = cleaned.strip_prefix("heddle ")
87 && let Some(tokens) = tokenize(rest)
88 {
89 result.push(DocsInvocation {
90 line: line_no,
91 raw: format!("heddle {}", rest.trim_end()),
92 tokens,
93 });
94 }
95 i = end + 1;
96 continue;
97 }
98 }
99 i += 1;
100 }
101 }
102 }
103 result
104}
105
106pub fn is_planned_docs_marker(line: &str) -> bool {
112 line.contains("doctor-docs:planned") || line.contains("doctor-docs: planned")
113}
114
115pub fn strip_shell_prefix(s: &str) -> &str {
117 let s = s.trim_start();
118 s.strip_prefix("$ ")
119 .or_else(|| s.strip_prefix("# "))
120 .unwrap_or(s)
121}
122
123pub fn tokenize(s: &str) -> Option<Vec<String>> {
126 let mut out = Vec::new();
127 let mut current = String::new();
128 let mut in_single = false;
129 let mut in_double = false;
130 for c in s.chars() {
131 match c {
132 '\'' if !in_double => in_single = !in_single,
133 '"' if !in_single => in_double = !in_double,
134 ' ' | '\t' if !in_single && !in_double => {
135 if !current.is_empty() {
136 out.push(std::mem::take(&mut current));
137 }
138 }
139 '|' | '&' | ';' if !in_single && !in_double => {
147 if !current.is_empty() {
148 out.push(std::mem::take(&mut current));
149 }
150 break;
151 }
152 _ => current.push(c),
153 }
154 }
155 if !current.is_empty() {
156 out.push(current);
157 }
158 if out.is_empty() {
159 return None;
160 }
161 Some(out)
162}
163
164pub fn looks_like_value(tok: &str) -> bool {
167 tok.contains('.') || tok.contains('/') || tok.starts_with('"')
168}
169
170pub fn display_path(repo_root: &Path, file: &Path) -> String {
172 file.strip_prefix(repo_root)
173 .unwrap_or(file)
174 .to_string_lossy()
175 .into_owned()
176}
177
178#[cfg(test)]
179mod tests {
180 use std::path::PathBuf;
181
182 use super::*;
183
184 #[test]
185 fn tokenize_splits_flags_and_preserves_placeholders() {
186 let tokens = tokenize("start <name> --path <dir> --workspace ephemeral").unwrap();
187 assert_eq!(
188 tokens,
189 vec![
190 "start",
191 "<name>",
192 "--path",
193 "<dir>",
194 "--workspace",
195 "ephemeral"
196 ]
197 );
198 }
199
200 #[test]
201 fn tokenize_stops_at_shell_control() {
202 let tokens = tokenize("status --output json | jq .").unwrap();
203 assert_eq!(tokens, vec!["status", "--output", "json"]);
204 }
205
206 #[test]
207 fn tokenize_respects_quotes() {
208 let tokens = tokenize("context set -m \"hello world\"").unwrap();
209 assert_eq!(tokens, vec!["context", "set", "-m", "hello world"]);
210 }
211
212 #[test]
213 fn strip_shell_prefix_variants() {
214 assert_eq!(strip_shell_prefix("$ heddle status"), "heddle status");
215 assert_eq!(strip_shell_prefix("# heddle status"), "heddle status");
216 assert_eq!(strip_shell_prefix(" heddle status"), "heddle status");
217 }
218
219 #[test]
220 fn planned_marker_detection() {
221 assert!(is_planned_docs_marker("<!-- doctor-docs:planned -->"));
222 assert!(is_planned_docs_marker("```sh doctor-docs: planned"));
223 assert!(!is_planned_docs_marker("```bash"));
224 }
225
226 #[test]
227 fn extract_inline_and_fenced_invocations() {
228 let text = "\
229Use `heddle status --output json` here.
230
231```bash
232$ heddle start probe --path /tmp
233```
234";
235 let inv = extract_invocations(text);
236 assert_eq!(inv.len(), 2);
237 assert_eq!(inv[0].tokens[0], "status");
238 assert_eq!(inv[0].line, 1);
239 assert_eq!(inv[1].tokens[0], "start");
240 assert!(inv[1].raw.starts_with("heddle start"));
241 }
242
243 #[test]
244 fn planned_marker_skips_next_inline_line() {
245 let text = "\
246<!-- doctor-docs:planned -->
247`heddle frobnicate --foo`
248`heddle status`
249";
250 let inv = extract_invocations(text);
251 assert_eq!(inv.len(), 1);
252 assert_eq!(inv[0].tokens[0], "status");
253 }
254
255 #[test]
256 fn planned_fence_info_skips_block() {
257 let text = "\
258```sh doctor-docs:planned
259heddle frobnicate --foo
260```
261`heddle status --output json`
262";
263 let inv = extract_invocations(text);
264 assert_eq!(inv.len(), 1);
265 assert_eq!(inv[0].tokens[0], "status");
266 }
267
268 #[test]
269 fn ignores_non_backticked_prose() {
270 let inv = extract_invocations("when using heddle status without backticks");
271 assert!(inv.is_empty());
272 }
273
274 #[test]
275 fn looks_like_value_heuristics() {
276 assert!(looks_like_value("src/lib.rs"));
277 assert!(looks_like_value("pkg.mod"));
278 assert!(looks_like_value("\"quoted\""));
279 assert!(!looks_like_value("marker"));
280 }
281
282 #[test]
283 fn display_path_strips_repo_root() {
284 let root = PathBuf::from("/repo");
285 let file = PathBuf::from("/repo/docs/guide.md");
286 assert_eq!(display_path(&root, &file), "docs/guide.md");
287 assert_eq!(
288 display_path(&root, Path::new("/elsewhere/x.md")),
289 "/elsewhere/x.md"
290 );
291 }
292}