Skip to main content

apm_core/
spec.rs

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        // Key inserted with supplied casing since no existing key matched
185        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}