1use anyhow::Result;
4use serde_json::{json, Value};
5use std::path::PathBuf;
6use std::str::FromStr;
7
8use crate::diagnose;
9use crate::paths::LOGS_DIR;
10use crate::spec::{load_all_specs, resolve_spec, SpecStatus, SpecType};
11use crate::spec_group;
12
13use super::super::handlers::mcp_ensure_initialized;
14use super::super::response::{mcp_error_response, mcp_text_response};
15
16pub fn tool_chant_spec_list(arguments: Option<&Value>) -> Result<Value> {
17 let specs_dir = match mcp_ensure_initialized() {
18 Ok(dir) => dir,
19 Err(err_response) => return Ok(err_response),
20 };
21
22 let mut specs = load_all_specs(&specs_dir)?;
23 specs.sort_by(|a, b| spec_group::compare_spec_ids(&a.id, &b.id));
24
25 if let Some(args) = arguments {
27 if let Some(status_str) = args.get("status").and_then(|v| v.as_str()) {
28 let filter_status = SpecStatus::from_str(status_str).ok();
29
30 if let Some(status) = filter_status {
31 specs.retain(|s| s.frontmatter.status == status);
32 }
33 } else {
34 specs.retain(|s| s.frontmatter.status != SpecStatus::Cancelled);
36 }
37 } else {
38 specs.retain(|s| s.frontmatter.status != SpecStatus::Cancelled);
40 }
41
42 let limit = arguments
44 .and_then(|a| a.get("limit"))
45 .and_then(|v| v.as_u64())
46 .unwrap_or(50) as usize;
47
48 let total = specs.len();
49 let limited_specs: Vec<_> = specs.into_iter().take(limit).collect();
50
51 let specs_json: Vec<Value> = limited_specs
52 .iter()
53 .map(|s| {
54 json!({
55 "id": s.id,
56 "title": s.title,
57 "status": format!("{:?}", s.frontmatter.status).to_lowercase(),
58 "type": s.frontmatter.r#type,
59 "depends_on": s.frontmatter.depends_on,
60 "labels": s.frontmatter.labels
61 })
62 })
63 .collect();
64
65 let response = json!({
66 "specs": specs_json,
67 "total": total,
68 "limit": limit,
69 "returned": limited_specs.len()
70 });
71
72 Ok(mcp_text_response(serde_json::to_string_pretty(&response)?))
73}
74
75pub fn tool_chant_spec_get(arguments: Option<&Value>) -> Result<Value> {
76 let specs_dir = match mcp_ensure_initialized() {
77 Ok(dir) => dir,
78 Err(err_response) => return Ok(err_response),
79 };
80
81 let id = arguments
82 .and_then(|a| a.get("id"))
83 .and_then(|v| v.as_str())
84 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
85
86 let spec = match resolve_spec(&specs_dir, id) {
87 Ok(s) => s,
88 Err(e) => {
89 return Ok(mcp_error_response(e.to_string()));
90 }
91 };
92
93 let spec_json = json!({
94 "id": spec.id,
95 "title": spec.title,
96 "status": format!("{:?}", spec.frontmatter.status).to_lowercase(),
97 "type": spec.frontmatter.r#type,
98 "depends_on": spec.frontmatter.depends_on,
99 "labels": spec.frontmatter.labels,
100 "target_files": spec.frontmatter.target_files,
101 "context": spec.frontmatter.context,
102 "prompt": spec.frontmatter.prompt,
103 "branch": spec.frontmatter.branch,
104 "commits": spec.frontmatter.commits,
105 "completed_at": spec.frontmatter.completed_at,
106 "model": spec.frontmatter.model,
107 "body": spec.body
108 });
109
110 Ok(mcp_text_response(serde_json::to_string_pretty(&spec_json)?))
111}
112
113pub fn tool_chant_ready(arguments: Option<&Value>) -> Result<Value> {
114 let specs_dir = match mcp_ensure_initialized() {
115 Ok(dir) => dir,
116 Err(err_response) => return Ok(err_response),
117 };
118
119 let mut specs = load_all_specs(&specs_dir)?;
120 specs.sort_by(|a, b| spec_group::compare_spec_ids(&a.id, &b.id));
121
122 let all_specs = specs.clone();
124 specs.retain(|s| s.is_ready(&all_specs));
125 specs.retain(|s| s.frontmatter.r#type != SpecType::Group);
127
128 let limit = arguments
130 .and_then(|a| a.get("limit"))
131 .and_then(|v| v.as_u64())
132 .unwrap_or(50) as usize;
133
134 let total = specs.len();
135 let limited_specs: Vec<_> = specs.into_iter().take(limit).collect();
136
137 let specs_json: Vec<Value> = limited_specs
138 .iter()
139 .map(|s| {
140 json!({
141 "id": s.id,
142 "title": s.title,
143 "status": format!("{:?}", s.frontmatter.status).to_lowercase(),
144 "type": s.frontmatter.r#type,
145 "depends_on": s.frontmatter.depends_on,
146 "labels": s.frontmatter.labels
147 })
148 })
149 .collect();
150
151 let response = json!({
152 "specs": specs_json,
153 "total": total,
154 "limit": limit,
155 "returned": limited_specs.len()
156 });
157
158 Ok(mcp_text_response(serde_json::to_string_pretty(&response)?))
159}
160
161pub fn tool_chant_spec_update(arguments: Option<&Value>) -> Result<Value> {
162 let specs_dir = match mcp_ensure_initialized() {
163 Ok(dir) => dir,
164 Err(err_response) => return Ok(err_response),
165 };
166
167 let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
168
169 let id = args
170 .get("id")
171 .and_then(|v| v.as_str())
172 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
173
174 let mut spec = match resolve_spec(&specs_dir, id) {
175 Ok(s) => s,
176 Err(e) => {
177 return Ok(mcp_error_response(e.to_string()));
178 }
179 };
180
181 let spec_id = spec.id.clone();
182 let spec_path = specs_dir.join(format!("{}.md", spec.id));
183
184 let status = if let Some(status_str) = args.get("status").and_then(|v| v.as_str()) {
186 match SpecStatus::from_str(status_str) {
187 Ok(s) => Some(s),
188 Err(e) => {
189 return Ok(mcp_error_response(format!("{}", e)));
190 }
191 }
192 } else {
193 None
194 };
195
196 let depends_on = args
198 .get("depends_on")
199 .and_then(|v| v.as_array())
200 .map(|arr| {
201 arr.iter()
202 .filter_map(|v| v.as_str().map(String::from))
203 .collect()
204 });
205
206 let labels = args.get("labels").and_then(|v| v.as_array()).map(|arr| {
207 arr.iter()
208 .filter_map(|v| v.as_str().map(String::from))
209 .collect()
210 });
211
212 let target_files = args
213 .get("target_files")
214 .and_then(|v| v.as_array())
215 .map(|arr| {
216 arr.iter()
217 .filter_map(|v| v.as_str().map(String::from))
218 .collect()
219 });
220
221 let model = args.get("model").and_then(|v| v.as_str()).map(String::from);
222
223 let output = args
224 .get("output")
225 .and_then(|v| v.as_str())
226 .map(String::from);
227
228 let replace_body = args
229 .get("replace_body")
230 .and_then(|v| v.as_bool())
231 .unwrap_or(false);
232
233 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
235
236 let options = crate::operations::update::UpdateOptions {
238 status,
239 depends_on,
240 labels,
241 target_files,
242 model,
243 output,
244 replace_body,
245 force,
246 };
247
248 match crate::operations::update::update_spec(&mut spec, &spec_path, options) {
249 Ok(_) => Ok(mcp_text_response(format!("Updated spec: {}", spec_id))),
250 Err(e) => Ok(mcp_error_response(format!("Failed to update spec: {}", e))),
251 }
252}
253
254pub fn tool_chant_status(arguments: Option<&Value>) -> Result<Value> {
255 let specs_dir = match mcp_ensure_initialized() {
256 Ok(dir) => dir,
257 Err(err_response) => return Ok(err_response),
258 };
259
260 let specs = load_all_specs(&specs_dir)?;
261
262 let brief = arguments
264 .and_then(|a| a.get("brief"))
265 .and_then(|v| v.as_bool())
266 .unwrap_or(false);
267 let include_activity = arguments
268 .and_then(|a| a.get("include_activity"))
269 .and_then(|v| v.as_bool())
270 .unwrap_or(false);
271
272 let mut pending = 0;
274 let mut in_progress = 0;
275 let mut completed = 0;
276 let mut failed = 0;
277 let mut blocked = 0;
278 let mut cancelled = 0;
279 let mut needs_attention = 0;
280
281 for spec in &specs {
282 match spec.frontmatter.status {
283 SpecStatus::Pending => pending += 1,
284 SpecStatus::InProgress => in_progress += 1,
285 SpecStatus::Paused => in_progress += 1, SpecStatus::Completed => completed += 1,
287 SpecStatus::Failed => failed += 1,
288 SpecStatus::Ready => pending += 1, SpecStatus::Blocked => blocked += 1,
290 SpecStatus::Cancelled => cancelled += 1,
291 SpecStatus::NeedsAttention => needs_attention += 1,
292 }
293 }
294
295 if brief {
297 let mut parts = vec![];
298 if pending > 0 {
299 parts.push(format!("{} pending", pending));
300 }
301 if in_progress > 0 {
302 parts.push(format!("{} in_progress", in_progress));
303 }
304 if completed > 0 {
305 parts.push(format!("{} completed", completed));
306 }
307 if failed > 0 {
308 parts.push(format!("{} failed", failed));
309 }
310 if blocked > 0 {
311 parts.push(format!("{} blocked", blocked));
312 }
313 if cancelled > 0 {
314 parts.push(format!("{} cancelled", cancelled));
315 }
316 if needs_attention > 0 {
317 parts.push(format!("{} needs_attention", needs_attention));
318 }
319 let brief_text = if parts.is_empty() {
320 "No specs".to_string()
321 } else {
322 parts.join(" | ")
323 };
324 return Ok(mcp_text_response(brief_text));
325 }
326
327 let mut status_json = json!({
329 "total": specs.len(),
330 "pending": pending,
331 "in_progress": in_progress,
332 "completed": completed,
333 "failed": failed,
334 "blocked": blocked,
335 "cancelled": cancelled,
336 "needs_attention": needs_attention
337 });
338
339 if include_activity {
341 let logs_dir = PathBuf::from(LOGS_DIR);
342 let mut activity: Vec<Value> = vec![];
343
344 for spec in &specs {
345 if spec.frontmatter.status != SpecStatus::InProgress {
346 continue;
347 }
348
349 let spec_path = specs_dir.join(format!("{}.md", spec.id));
350 let log_path = logs_dir.join(format!("{}.log", spec.id));
351
352 let spec_mtime = std::fs::metadata(&spec_path)
354 .and_then(|m| m.modified())
355 .ok()
356 .map(|t| {
357 chrono::DateTime::<chrono::Local>::from(t)
358 .format("%Y-%m-%d %H:%M:%S")
359 .to_string()
360 });
361
362 let log_mtime = std::fs::metadata(&log_path)
364 .and_then(|m| m.modified())
365 .ok()
366 .map(|t| {
367 chrono::DateTime::<chrono::Local>::from(t)
368 .format("%Y-%m-%d %H:%M:%S")
369 .to_string()
370 });
371
372 let has_log = log_path.exists();
374
375 activity.push(json!({
376 "id": spec.id,
377 "title": spec.title,
378 "spec_modified": spec_mtime,
379 "log_modified": log_mtime,
380 "has_log": has_log
381 }));
382 }
383
384 status_json["in_progress_activity"] = json!(activity);
385 }
386
387 Ok(mcp_text_response(serde_json::to_string_pretty(
388 &status_json,
389 )?))
390}
391
392pub fn tool_chant_log(arguments: Option<&Value>) -> Result<Value> {
393 let specs_dir = match mcp_ensure_initialized() {
394 Ok(dir) => dir,
395 Err(err_response) => return Ok(err_response),
396 };
397
398 let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
399
400 let id = args
401 .get("id")
402 .and_then(|v| v.as_str())
403 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
404
405 let lines = args
406 .get("lines")
407 .and_then(|v| v.as_u64())
408 .map(|v| v as usize);
409 let byte_offset = args.get("offset").and_then(|v| v.as_u64());
410 let since = args.get("since").and_then(|v| v.as_str());
411
412 let spec = match resolve_spec(&specs_dir, id) {
414 Ok(s) => s,
415 Err(e) => {
416 return Ok(mcp_error_response(e.to_string()));
417 }
418 };
419
420 let logs_dir = PathBuf::from(LOGS_DIR);
421 let log_path = logs_dir.join(format!("{}.log", spec.id));
422
423 if !log_path.exists() {
424 return Ok(mcp_error_response(format!("No log file found for spec '{}'. Logs are created when a spec is executed with `chant work`.", spec.id)));
425 }
426
427 let content = std::fs::read_to_string(&log_path)?;
429 let file_byte_len = content.len() as u64;
430
431 let content_after_offset = if let Some(offset) = byte_offset {
433 if offset >= file_byte_len {
434 String::new()
436 } else {
437 content[(offset as usize)..].to_string()
438 }
439 } else {
440 content.clone()
441 };
442
443 let content_after_since = if let Some(since_ts) = since {
445 if let Ok(since_time) = chrono::DateTime::parse_from_rfc3339(since_ts) {
446 content_after_offset
447 .lines()
448 .filter(|line| {
449 if line.len() >= 24 {
452 if let Ok(line_time) = chrono::DateTime::parse_from_rfc3339(&line[..24]) {
453 return line_time > since_time;
454 }
455 }
456 true })
458 .collect::<Vec<&str>>()
459 .join("\n")
460 } else {
461 content_after_offset
462 }
463 } else {
464 content_after_offset
465 };
466
467 let all_lines: Vec<&str> = content_after_since.lines().collect();
469 let lines_limit = lines.unwrap_or(100);
470 let start = if all_lines.len() > lines_limit {
471 all_lines.len() - lines_limit
472 } else {
473 0
474 };
475 let log_output = all_lines[start..].join("\n");
476
477 let new_byte_offset = if byte_offset.is_some() {
479 file_byte_len
480 } else {
481 content.len() as u64
482 };
483
484 let has_more = all_lines.len() > lines_limit;
485 let line_count = all_lines[start..].len();
486
487 let response = json!({
488 "content": log_output,
489 "byte_offset": new_byte_offset,
490 "line_count": line_count,
491 "has_more": has_more
492 });
493
494 Ok(mcp_text_response(serde_json::to_string_pretty(&response)?))
495}
496
497pub fn tool_chant_search(arguments: Option<&Value>) -> Result<Value> {
498 let specs_dir = match mcp_ensure_initialized() {
499 Ok(dir) => dir,
500 Err(err_response) => return Ok(err_response),
501 };
502
503 let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
504
505 let query = args
506 .get("query")
507 .and_then(|v| v.as_str())
508 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: query"))?
509 .to_lowercase();
510
511 let status_filter = args.get("status").and_then(|v| v.as_str());
512
513 let mut specs = load_all_specs(&specs_dir)?;
514
515 specs.retain(|s| {
517 let title_match = s
518 .title
519 .as_ref()
520 .map(|t| t.to_lowercase().contains(&query))
521 .unwrap_or(false);
522 title_match || s.body.to_lowercase().contains(&query)
523 });
524
525 if let Some(status_str) = status_filter {
527 let filter_status = SpecStatus::from_str(status_str).ok();
528
529 if let Some(status) = filter_status {
530 specs.retain(|s| s.frontmatter.status == status);
531 }
532 }
533
534 specs.sort_by(|a, b| spec_group::compare_spec_ids(&a.id, &b.id));
535
536 let specs_json: Vec<Value> = specs
537 .iter()
538 .map(|s| {
539 json!({
540 "id": s.id,
541 "title": s.title,
542 "status": format!("{:?}", s.frontmatter.status).to_lowercase(),
543 "type": s.frontmatter.r#type
544 })
545 })
546 .collect();
547
548 Ok(mcp_text_response(serde_json::to_string_pretty(
549 &specs_json,
550 )?))
551}
552
553pub fn tool_chant_diagnose(arguments: Option<&Value>) -> Result<Value> {
554 let specs_dir = match mcp_ensure_initialized() {
555 Ok(dir) => dir,
556 Err(err_response) => return Ok(err_response),
557 };
558
559 let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
560
561 let id = args
562 .get("id")
563 .and_then(|v| v.as_str())
564 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
565
566 let spec = match resolve_spec(&specs_dir, id) {
568 Ok(s) => s,
569 Err(e) => {
570 return Ok(mcp_error_response(e.to_string()));
571 }
572 };
573
574 let report = match diagnose::diagnose_spec(&spec.id) {
576 Ok(r) => r,
577 Err(e) => {
578 return Ok(mcp_error_response(format!(
579 "Failed to diagnose spec: {}",
580 e
581 )));
582 }
583 };
584
585 let checks_json: Vec<Value> = report
587 .checks
588 .iter()
589 .map(|c| {
590 json!({
591 "name": c.name,
592 "passed": c.passed,
593 "details": c.details
594 })
595 })
596 .collect();
597
598 let report_json = json!({
599 "spec_id": report.spec_id,
600 "status": format!("{:?}", report.status).to_lowercase(),
601 "location": report.location,
602 "checks": checks_json,
603 "diagnosis": report.diagnosis,
604 "suggestion": report.suggestion
605 });
606
607 Ok(mcp_text_response(serde_json::to_string_pretty(
608 &report_json,
609 )?))
610}
611
612pub fn tool_chant_lint(arguments: Option<&Value>) -> Result<Value> {
613 let specs_dir = match mcp_ensure_initialized() {
614 Ok(dir) => dir,
615 Err(err_response) => return Ok(err_response),
616 };
617
618 let spec_id = arguments
619 .and_then(|args| args.get("id"))
620 .and_then(|v| v.as_str());
621
622 use crate::config::Config;
623 use crate::score::traffic_light;
624 use crate::scoring::{calculate_spec_score, TrafficLight};
625 use crate::spec::Spec;
626
627 let config = match Config::load() {
629 Ok(c) => c,
630 Err(e) => {
631 return Ok(mcp_error_response(format!("Failed to load config: {}", e)));
632 }
633 };
634
635 let all_specs = load_all_specs(&specs_dir)?;
637
638 let specs_to_check: Vec<Spec> = if let Some(id) = spec_id {
640 match resolve_spec(&specs_dir, id) {
641 Ok(spec) => vec![spec],
642 Err(e) => {
643 return Ok(mcp_error_response(e.to_string()));
644 }
645 }
646 } else {
647 all_specs.clone()
648 };
649
650 let mut results: Vec<Value> = Vec::new();
651 let mut red_count = 0;
652 let mut yellow_count = 0;
653 let mut green_count = 0;
654
655 for spec in &specs_to_check {
657 let score = calculate_spec_score(spec, &all_specs, &config);
658 let suggestions = traffic_light::generate_suggestions(&score);
659
660 let traffic_light_str = match score.traffic_light {
661 TrafficLight::Ready => {
662 green_count += 1;
663 "green"
664 }
665 TrafficLight::Review => {
666 yellow_count += 1;
667 "yellow"
668 }
669 TrafficLight::Refine => {
670 red_count += 1;
671 "red"
672 }
673 };
674
675 results.push(json!({
676 "id": spec.id,
677 "title": spec.title,
678 "traffic_light": traffic_light_str,
679 "complexity": score.complexity.to_string(),
680 "confidence": score.confidence.to_string(),
681 "splittability": score.splittability.to_string(),
682 "ac_quality": score.ac_quality.to_string(),
683 "isolation": score.isolation.map(|i| i.to_string()),
684 "suggestions": suggestions
685 }));
686 }
687
688 let summary = json!({
689 "specs_checked": specs_to_check.len(),
690 "red": red_count,
691 "yellow": yellow_count,
692 "green": green_count,
693 "results": results
694 });
695
696 Ok(mcp_text_response(serde_json::to_string_pretty(&summary)?))
697}
698
699pub fn tool_chant_add(arguments: Option<&Value>) -> Result<Value> {
700 let specs_dir = match mcp_ensure_initialized() {
701 Ok(dir) => dir,
702 Err(err_response) => return Ok(err_response),
703 };
704
705 let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
706
707 let description = args
708 .get("description")
709 .and_then(|v| v.as_str())
710 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: description"))?;
711
712 let prompt = args.get("prompt").and_then(|v| v.as_str());
713
714 let config = match crate::config::Config::load() {
716 Ok(c) => c,
717 Err(e) => {
718 return Ok(mcp_error_response(format!("Failed to load config: {}", e)));
719 }
720 };
721
722 let options = crate::operations::create::CreateOptions {
724 prompt: prompt.map(String::from),
725 needs_approval: false,
726 auto_commit: false, };
728
729 let (spec, _filepath) =
730 match crate::operations::create::create_spec(description, &specs_dir, &config, options) {
731 Ok(result) => result,
732 Err(e) => {
733 return Ok(mcp_error_response(format!("Failed to create spec: {}", e)));
734 }
735 };
736
737 let all_specs = match load_all_specs(&specs_dir) {
739 Ok(specs) => specs,
740 Err(e) => {
741 return Ok(mcp_error_response(format!("Failed to load specs: {}", e)));
742 }
743 };
744
745 use crate::score::traffic_light;
747 use crate::scoring::calculate_spec_score;
748
749 let score = calculate_spec_score(&spec, &all_specs, &config);
750 let suggestions = traffic_light::generate_suggestions(&score);
751
752 let traffic_light_str = match score.traffic_light {
754 crate::scoring::TrafficLight::Ready => "green",
755 crate::scoring::TrafficLight::Review => "yellow",
756 crate::scoring::TrafficLight::Refine => "red",
757 };
758
759 let response = json!({
761 "spec_id": spec.id,
762 "message": format!("Created spec: {}", spec.id),
763 "lint": {
764 "traffic_light": traffic_light_str,
765 "complexity": score.complexity.to_string(),
766 "confidence": score.confidence.to_string(),
767 "splittability": score.splittability.to_string(),
768 "ac_quality": score.ac_quality.to_string(),
769 "isolation": score.isolation.map(|i| i.to_string()),
770 "suggestions": suggestions
771 }
772 });
773
774 Ok(mcp_text_response(serde_json::to_string_pretty(&response)?))
775}
776
777pub fn tool_chant_verify(arguments: Option<&Value>) -> Result<Value> {
778 let specs_dir = match mcp_ensure_initialized() {
779 Ok(dir) => dir,
780 Err(err_response) => return Ok(err_response),
781 };
782
783 let args = arguments.ok_or_else(|| anyhow::anyhow!("Missing arguments"))?;
784
785 let id = args
786 .get("id")
787 .and_then(|v| v.as_str())
788 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
789
790 let spec = match resolve_spec(&specs_dir, id) {
791 Ok(s) => s,
792 Err(e) => {
793 return Ok(mcp_error_response(e.to_string()));
794 }
795 };
796
797 let spec_id = spec.id.clone();
798
799 let unchecked_count = spec.count_unchecked_checkboxes();
801
802 let total_count: usize = {
804 let acceptance_criteria_marker = "## Acceptance Criteria";
805 let mut in_ac_section = false;
806 let mut in_code_fence = false;
807 let mut count = 0;
808
809 for line in spec.body.lines() {
810 let trimmed = line.trim_start();
811
812 if trimmed.starts_with("```") {
813 in_code_fence = !in_code_fence;
814 continue;
815 }
816
817 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
818 in_ac_section = true;
819 continue;
820 }
821
822 if in_ac_section && trimmed.starts_with("## ") && !in_code_fence {
823 break;
824 }
825
826 if in_ac_section
827 && !in_code_fence
828 && (trimmed.starts_with("- [x]") || trimmed.starts_with("- [ ]"))
829 {
830 count += 1;
831 }
832 }
833
834 count
835 };
836
837 let checked_count = total_count.saturating_sub(unchecked_count);
838 let verified = unchecked_count == 0 && total_count > 0;
839
840 let unchecked_items = if unchecked_count > 0 {
842 let acceptance_criteria_marker = "## Acceptance Criteria";
843 let mut in_ac_section = false;
844 let mut in_code_fence = false;
845 let mut items = Vec::new();
846
847 for line in spec.body.lines() {
848 let trimmed = line.trim_start();
849
850 if trimmed.starts_with("```") {
851 in_code_fence = !in_code_fence;
852 continue;
853 }
854
855 if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
856 in_ac_section = true;
857 continue;
858 }
859
860 if in_ac_section && trimmed.starts_with("## ") && !in_code_fence {
861 break;
862 }
863
864 if in_ac_section && !in_code_fence && trimmed.starts_with("- [ ]") {
865 items.push(trimmed.to_string());
866 }
867 }
868
869 items
870 } else {
871 Vec::new()
872 };
873
874 let verification_notes = if total_count == 0 {
875 "No acceptance criteria found".to_string()
876 } else if verified {
877 "All acceptance criteria met".to_string()
878 } else {
879 format!("{} criteria not yet checked", unchecked_count)
880 };
881
882 let result = json!({
883 "spec_id": spec_id,
884 "verified": verified,
885 "criteria": {
886 "total": total_count,
887 "checked": checked_count,
888 "unchecked": unchecked_count
889 },
890 "unchecked_items": unchecked_items,
891 "verification_notes": verification_notes
892 });
893
894 Ok(mcp_text_response(serde_json::to_string_pretty(&result)?))
895}