1use crate::attrs::parse_attrs;
7use crate::types::{AttrValue, InlineExt};
8
9pub fn scan_inline_extensions(text: &str) -> Vec<(usize, usize, InlineExt)> {
15 let mut results = Vec::new();
16 let bytes = text.as_bytes();
17 let len = bytes.len();
18 let mut pos = 0;
19
20 while pos < len {
21 if bytes[pos] == b':' {
23 if pos > 0 && bytes[pos - 1] == b':' {
25 pos += 1;
26 continue;
27 }
28 if pos + 1 < len && bytes[pos + 1] == b':' {
30 pos += 2;
31 continue;
32 }
33
34 if let Some(ext) = try_parse_extension(text, pos) {
36 let end = ext.1;
37 results.push(ext);
38 pos = end;
40 continue;
41 }
42 }
43 pos += 1;
44 }
45
46 results
47}
48
49fn try_parse_extension(text: &str, colon_pos: usize) -> Option<(usize, usize, InlineExt)> {
53 let rest = &text[colon_pos + 1..];
54
55 let (name, after_name) = if let Some(stripped) = rest.strip_prefix("evidence[") {
56 ("evidence", stripped)
57 } else if let Some(stripped) = rest.strip_prefix("status[") {
58 ("status", stripped)
59 } else {
60 return None;
61 };
62
63 let bracket_close = after_name.find(']')?;
65 let attr_str = &after_name[..bracket_close];
66
67 let end_pos = colon_pos + 1 + name.len() + 1 + bracket_close + 1;
70
71 let attrs = parse_attrs(attr_str).ok()?;
73
74 match name {
75 "evidence" => {
76 let tier = attrs.get("tier").and_then(|v| match v {
77 AttrValue::Number(n) => Some(*n as u8),
78 AttrValue::String(s) => s.parse::<u8>().ok(),
79 _ => None,
80 });
81 let source = attrs.get("source").and_then(|v| match v {
82 AttrValue::String(s) => Some(s.clone()),
83 _ => None,
84 });
85 Some((
87 colon_pos,
88 end_pos,
89 InlineExt::Evidence {
90 tier,
91 source,
92 text: attr_str.trim().to_string(),
93 },
94 ))
95 }
96 "status" => {
97 let value = attrs
98 .get("value")
99 .and_then(|v| match v {
100 AttrValue::String(s) => Some(s.clone()),
101 AttrValue::Bool(b) => Some(b.to_string()),
102 AttrValue::Number(n) => Some(n.to_string()),
103 AttrValue::Null => None,
104 })
105 .unwrap_or_default();
106 Some((colon_pos, end_pos, InlineExt::Status { value }))
107 }
108 _ => None,
109 }
110}
111
112#[cfg(test)]
117mod tests {
118 use super::*;
119 use pretty_assertions::assert_eq;
120
121 #[test]
122 fn scan_evidence_basic() {
123 let text = r#"Some text :evidence[tier=1 source="Gartner"] more text"#;
124 let results = scan_inline_extensions(text);
125 assert_eq!(results.len(), 1);
126 match &results[0].2 {
127 InlineExt::Evidence { tier, source, .. } => {
128 assert_eq!(*tier, Some(1));
129 assert_eq!(source.as_deref(), Some("Gartner"));
130 }
131 other => panic!("Expected Evidence, got {other:?}"),
132 }
133 }
134
135 #[test]
136 fn scan_status_basic() {
137 let text = ":status[value=shipped] and done";
138 let results = scan_inline_extensions(text);
139 assert_eq!(results.len(), 1);
140 match &results[0].2 {
141 InlineExt::Status { value } => {
142 assert_eq!(value, "shipped");
143 }
144 other => panic!("Expected Status, got {other:?}"),
145 }
146 }
147
148 #[test]
149 fn scan_multiple_inline() {
150 let text = r#":status[value=done] and :evidence[tier=2 source="IEEE"] end"#;
151 let results = scan_inline_extensions(text);
152 assert_eq!(results.len(), 2);
153 assert!(matches!(&results[0].2, InlineExt::Status { .. }));
154 assert!(matches!(&results[1].2, InlineExt::Evidence { .. }));
155 }
156
157 #[test]
158 fn scan_no_extensions() {
159 let text = "Just plain text with no extensions.";
160 let results = scan_inline_extensions(text);
161 assert!(results.is_empty());
162 }
163
164 #[test]
165 fn scan_double_colon_ignored() {
166 let text = "::evidence[tier=1] should not match as inline";
167 let results = scan_inline_extensions(text);
168 assert!(results.is_empty(), "Double-colon should not be matched: {results:?}");
169 }
170}