1use anyhow::{bail, Result};
2use crate::config::SectionType;
3use crate::ticket::TicketDocument;
4
5pub fn get_section(doc: &TicketDocument, name: &str) -> Option<String> {
6 let lower = name.to_lowercase();
7 doc.sections.iter()
8 .find(|(k, _)| k.to_lowercase() == lower)
9 .map(|(_, v)| v.clone())
10}
11
12pub fn set_section(doc: &mut TicketDocument, name: &str, value: String) {
13 let lower = name.to_lowercase();
14 if let Some(k) = doc.sections.keys().find(|k| k.to_lowercase() == lower).cloned() {
15 doc.sections.insert(k, value);
16 } else {
17 doc.sections.insert(name.to_string(), value);
18 }
19}
20
21pub fn append_section(doc: &mut TicketDocument, name: &str, value: String) {
22 let existing = get_section(doc, name).unwrap_or_default();
23 let new_value = if existing.trim().is_empty() {
24 value
25 } else {
26 format!("{}\n{}", existing.trim_end(), value)
27 };
28 set_section(doc, name, new_value);
29}
30
31pub fn apply_section_type(type_: &SectionType, value: String) -> String {
32 match type_ {
33 SectionType::Tasks => value
34 .lines()
35 .map(|line| {
36 let l = line.trim();
37 if l.is_empty() {
38 String::new()
39 } else if l.starts_with("- [ ] ") || l.starts_with("- [x] ") || l.starts_with("- [X] ") {
40 l.to_string()
41 } else {
42 format!("- [ ] {l}")
43 }
44 })
45 .collect::<Vec<_>>()
46 .join("\n"),
47 SectionType::Qa => value
48 .lines()
49 .map(|line| {
50 let l = line.trim();
51 if l.is_empty() {
52 String::new()
53 } else {
54 format!("**Q:** {l}")
55 }
56 })
57 .collect::<Vec<_>>()
58 .join("\n"),
59 SectionType::Free => value,
60 }
61}
62
63pub fn mark_item(content: &str, section: &str, item_text: &str) -> Result<String> {
64 let lines: Vec<&str> = content.lines().collect();
65 let section_lower = section.to_lowercase();
66
67 let header_idx = lines.iter().position(|line| {
68 line.strip_prefix("### ")
69 .map(|rest| rest.to_lowercase() == section_lower)
70 .unwrap_or(false)
71 });
72
73 let Some(header_idx) = header_idx else {
74 bail!("section {:?} not found", section);
75 };
76
77 let mut matches: Vec<usize> = Vec::new();
78 for (i, line) in lines.iter().enumerate().skip(header_idx + 1) {
79 if line.starts_with("##") {
80 break;
81 }
82 if let Some(text) = line.strip_prefix("- [ ] ") {
83 if text.to_lowercase().contains(&item_text.to_lowercase()) {
84 matches.push(i);
85 }
86 }
87 }
88
89 match matches.len() {
90 0 => bail!(
91 "no unchecked item matching {:?} found in section {:?}",
92 item_text,
93 section
94 ),
95 1 => {
96 let mut new_lines: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
97 new_lines[matches[0]] = new_lines[matches[0]].replacen("- [ ] ", "- [x] ", 1);
98 let joined = new_lines.join("\n");
99 if content.ends_with('\n') {
100 Ok(joined + "\n")
101 } else {
102 Ok(joined)
103 }
104 }
105 _ => {
106 let mut msg = format!(
107 "ambiguous: {} unchecked items match {:?} in section {:?}:",
108 matches.len(),
109 item_text,
110 section
111 );
112 for i in &matches {
113 msg.push_str(&format!("\n {}", lines[*i]));
114 }
115 bail!("{}", msg);
116 }
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use crate::ticket::TicketDocument;
124 use crate::config::SectionType;
125
126 fn base_doc() -> TicketDocument {
127 TicketDocument::parse(
128 "## Spec\n\n### Problem\n\nA bug exists\n\
129 \n### Acceptance criteria\n\n- [ ] Fix the bug\n- [x] Write tests\n\
130 \n### Out of scope\n\nNothing\n\
131 \n### Approach\n\nUse a hammer\n\
132 \n### Open questions\n\nWhy?\n",
133 )
134 .unwrap()
135 }
136
137 #[test]
138 fn get_section_problem() {
139 let doc = base_doc();
140 assert_eq!(get_section(&doc, "Problem"), Some("A bug exists".to_string()));
141 }
142
143 #[test]
144 fn get_section_acceptance_criteria_markdown() {
145 let doc = base_doc();
146 let result = get_section(&doc, "Acceptance criteria").unwrap();
147 assert!(result.contains("- [ ] Fix the bug"));
148 assert!(result.contains("- [x] Write tests"));
149 }
150
151 #[test]
152 fn get_section_unknown_returns_none() {
153 let doc = base_doc();
154 assert_eq!(get_section(&doc, "Nonexistent"), None);
155 }
156
157 #[test]
158 fn get_section_case_insensitive() {
159 let doc = base_doc();
160 assert_eq!(get_section(&doc, "problem"), Some("A bug exists".to_string()));
161 assert_eq!(get_section(&doc, "PROBLEM"), Some("A bug exists".to_string()));
162 }
163
164 #[test]
165 fn set_section_problem_case_insensitive() {
166 let mut doc = base_doc();
167 set_section(&mut doc, "problem", "New problem".to_string());
168 assert_eq!(doc.sections.get("Problem").map(|s| s.as_str()), Some("New problem"));
169 }
170
171 #[test]
172 fn set_section_acceptance_criteria_stores_raw() {
173 let mut doc = base_doc();
174 set_section(&mut doc, "acceptance criteria", "- [ ] Item one\n- [x] Item two".to_string());
175 let val = doc.sections.get("Acceptance criteria").unwrap();
176 assert!(val.contains("- [ ] Item one"));
177 assert!(val.contains("- [x] Item two"));
178 }
179
180 #[test]
181 fn set_section_amendment_requests_stores_raw() {
182 let mut doc = base_doc();
183 set_section(&mut doc, "amendment requests", "- [ ] Fix docs".to_string());
184 let val = doc.sections.iter()
186 .find(|(k, _)| k.to_lowercase() == "amendment requests")
187 .map(|(_, v)| v.as_str());
188 assert_eq!(val, Some("- [ ] Fix docs"));
189 }
190
191 #[test]
192 fn set_section_new_key_appended() {
193 let mut doc = base_doc();
194 set_section(&mut doc, "New section", "Some content".to_string());
195 assert_eq!(get_section(&doc, "New section"), Some("Some content".to_string()));
196 }
197
198 #[test]
199 fn append_section_adds_to_existing() {
200 let mut doc = base_doc();
201 set_section(&mut doc, "Problem", "A bug exists".to_string());
202 append_section(&mut doc, "Problem", "More details".to_string());
203 assert_eq!(get_section(&doc, "Problem"), Some("A bug exists\nMore details".to_string()));
204 }
205
206 #[test]
207 fn append_section_creates_when_absent() {
208 let mut doc = base_doc();
209 append_section(&mut doc, "Amendment requests", "- [ ] Fix docs".to_string());
210 assert_eq!(get_section(&doc, "Amendment requests"), Some("- [ ] Fix docs".to_string()));
211 }
212
213 #[test]
214 fn append_section_treats_empty_section_as_absent() {
215 let mut doc = base_doc();
216 set_section(&mut doc, "Problem", " ".to_string());
217 append_section(&mut doc, "Problem", "Fresh content".to_string());
218 assert_eq!(get_section(&doc, "Problem"), Some("Fresh content".to_string()));
219 }
220
221 #[test]
222 fn apply_section_type_tasks_wraps_bare_line() {
223 let result = apply_section_type(&SectionType::Tasks, "Do something".to_string());
224 assert_eq!(result, "- [ ] Do something");
225 }
226
227 #[test]
228 fn apply_section_type_tasks_leaves_formatted_unchanged() {
229 let result = apply_section_type(&SectionType::Tasks, "- [ ] Already formatted".to_string());
230 assert_eq!(result, "- [ ] Already formatted");
231 }
232
233 #[test]
234 fn apply_section_type_qa_prefixes_line() {
235 let result = apply_section_type(&SectionType::Qa, "What is it?".to_string());
236 assert_eq!(result, "**Q:** What is it?");
237 }
238
239 #[test]
240 fn apply_section_type_free_unchanged() {
241 let result = apply_section_type(&SectionType::Free, "Some text".to_string());
242 assert_eq!(result, "Some text");
243 }
244
245 #[test]
246 fn mark_item_replaces_unchecked() {
247 let content = "### Acceptance criteria\n- [ ] Fix the bug\n- [ ] Write tests\n";
248 let result = mark_item(content, "Acceptance criteria", "Fix the bug").unwrap();
249 assert!(result.contains("- [x] Fix the bug"));
250 assert!(result.contains("- [ ] Write tests"));
251 }
252
253 #[test]
254 fn mark_item_error_no_match() {
255 let content = "### Acceptance criteria\n- [ ] Fix the bug\n";
256 let err = mark_item(content, "Acceptance criteria", "nonexistent").unwrap_err();
257 assert!(err.to_string().contains("no unchecked item"));
258 }
259
260 #[test]
261 fn mark_item_error_ambiguous() {
262 let content = "### Acceptance criteria\n- [ ] Fix the bug now\n- [ ] Fix the bug later\n";
263 let err = mark_item(content, "Acceptance criteria", "Fix the bug").unwrap_err();
264 assert!(err.to_string().contains("ambiguous"));
265 }
266}