1use super::report_templates;
8use async_trait::async_trait;
9use serde_json::json;
10use std::collections::HashMap;
11use std::fmt::Write as _;
12use zeroclaw_api::tool::{Tool, ToolResult};
13
14pub struct ProjectIntelTool {
18 default_language: String,
19 risk_sensitivity: RiskSensitivity,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum RiskSensitivity {
25 Low,
26 Medium,
27 High,
28}
29
30impl RiskSensitivity {
31 fn from_str(s: &str) -> Self {
32 match s.to_lowercase().as_str() {
33 "low" => Self::Low,
34 "high" => Self::High,
35 _ => Self::Medium,
36 }
37 }
38
39 fn threshold_factor(self) -> f64 {
41 match self {
42 Self::Low => 1.5,
43 Self::Medium => 1.0,
44 Self::High => 0.5,
45 }
46 }
47}
48
49impl ProjectIntelTool {
50 pub fn new(default_language: String, risk_sensitivity: String) -> Self {
51 Self {
52 default_language,
53 risk_sensitivity: RiskSensitivity::from_str(&risk_sensitivity),
54 }
55 }
56
57 fn execute_status_report(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
58 let project_name = args
59 .get("project_name")
60 .and_then(|v| v.as_str())
61 .filter(|s| !s.trim().is_empty())
62 .ok_or_else(|| {
63 ::zeroclaw_log::record!(
64 WARN,
65 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
66 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
67 .with_attrs(::serde_json::json!({
68 "action": "status_report",
69 "param": "project_name",
70 })),
71 "project_intel: status_report missing project_name"
72 );
73 anyhow::Error::msg("missing required 'project_name' for status_report")
74 })?;
75 let period = args
76 .get("period")
77 .and_then(|v| v.as_str())
78 .filter(|s| !s.trim().is_empty())
79 .ok_or_else(|| {
80 ::zeroclaw_log::record!(
81 WARN,
82 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
83 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
84 .with_attrs(::serde_json::json!({
85 "action": "status_report",
86 "param": "period",
87 })),
88 "project_intel: status_report missing period"
89 );
90 anyhow::Error::msg("missing required 'period' for status_report")
91 })?;
92 let lang = args
93 .get("language")
94 .and_then(|v| v.as_str())
95 .unwrap_or(&self.default_language);
96 let git_log = args
97 .get("git_log")
98 .and_then(|v| v.as_str())
99 .unwrap_or("No git data provided");
100 let jira_summary = args
101 .get("jira_summary")
102 .and_then(|v| v.as_str())
103 .unwrap_or("No Jira data provided");
104 let notes = args.get("notes").and_then(|v| v.as_str()).unwrap_or("");
105
106 let tpl = report_templates::weekly_status_template(lang);
107 let mut vars = HashMap::new();
108 vars.insert("project_name".into(), project_name.to_string());
109 vars.insert("period".into(), period.to_string());
110 vars.insert("completed".into(), git_log.to_string());
111 vars.insert("in_progress".into(), jira_summary.to_string());
112 vars.insert("blocked".into(), notes.to_string());
113 vars.insert("next_steps".into(), "To be determined".into());
114
115 let rendered = tpl.render(&vars);
116 Ok(ToolResult {
117 success: true,
118 output: rendered,
119 error: None,
120 })
121 }
122
123 fn execute_risk_scan(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
124 let deadlines = args
125 .get("deadlines")
126 .and_then(|v| v.as_str())
127 .unwrap_or_default();
128 let velocity = args
129 .get("velocity")
130 .and_then(|v| v.as_str())
131 .unwrap_or_default();
132 let blockers = args
133 .get("blockers")
134 .and_then(|v| v.as_str())
135 .unwrap_or_default();
136 let lang = args
137 .get("language")
138 .and_then(|v| v.as_str())
139 .unwrap_or(&self.default_language);
140
141 let mut risks = Vec::new();
142
143 let factor = self.risk_sensitivity.threshold_factor();
145
146 if !blockers.is_empty() {
147 let blocker_count = blockers.lines().filter(|l| !l.trim().is_empty()).count();
148 let severity = if (blocker_count as f64) > 3.0 * factor {
149 "critical"
150 } else if (blocker_count as f64) > 1.0 * factor {
151 "high"
152 } else {
153 "medium"
154 };
155 risks.push(RiskItem {
156 title: "Active blockers detected".into(),
157 severity: severity.into(),
158 detail: format!("{blocker_count} blocker(s) identified"),
159 mitigation: "Escalate blockers, assign owners, set resolution deadlines".into(),
160 });
161 }
162
163 if deadlines.to_lowercase().contains("overdue")
164 || deadlines.to_lowercase().contains("missed")
165 {
166 risks.push(RiskItem {
167 title: "Deadline risk".into(),
168 severity: "high".into(),
169 detail: "Overdue or missed deadlines detected in project context".into(),
170 mitigation: "Re-prioritize scope, negotiate timeline, add resources".into(),
171 });
172 }
173
174 if velocity.to_lowercase().contains("declining") || velocity.to_lowercase().contains("slow")
175 {
176 risks.push(RiskItem {
177 title: "Velocity degradation".into(),
178 severity: "medium".into(),
179 detail: "Team velocity is declining or below expectations".into(),
180 mitigation: "Identify bottlenecks, reduce WIP, address technical debt".into(),
181 });
182 }
183
184 if risks.is_empty() {
185 risks.push(RiskItem {
186 title: "No significant risks detected".into(),
187 severity: "low".into(),
188 detail: "Current project signals within normal parameters".into(),
189 mitigation: "Continue monitoring".into(),
190 });
191 }
192
193 let tpl = report_templates::risk_register_template(lang);
194 let risks_text = risks
195 .iter()
196 .map(|r| {
197 format!(
198 "- [{}] {}: {}",
199 r.severity.to_uppercase(),
200 r.title,
201 r.detail
202 )
203 })
204 .collect::<Vec<_>>()
205 .join("\n");
206 let mitigations_text = risks
207 .iter()
208 .map(|r| format!("- {}: {}", r.title, r.mitigation))
209 .collect::<Vec<_>>()
210 .join("\n");
211
212 let mut vars = HashMap::new();
213 vars.insert(
214 "project_name".into(),
215 args.get("project_name")
216 .and_then(|v| v.as_str())
217 .unwrap_or("Unknown")
218 .to_string(),
219 );
220 vars.insert("risks".into(), risks_text);
221 vars.insert("mitigations".into(), mitigations_text);
222
223 Ok(ToolResult {
224 success: true,
225 output: tpl.render(&vars),
226 error: None,
227 })
228 }
229
230 fn execute_draft_update(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
231 let project_name = args
232 .get("project_name")
233 .and_then(|v| v.as_str())
234 .filter(|s| !s.trim().is_empty())
235 .ok_or_else(|| {
236 ::zeroclaw_log::record!(
237 WARN,
238 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
239 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
240 .with_attrs(::serde_json::json!({
241 "action": "draft_update",
242 "param": "project_name",
243 })),
244 "project_intel: draft_update missing project_name"
245 );
246 anyhow::Error::msg("missing required 'project_name' for draft_update")
247 })?;
248 let audience = args
249 .get("audience")
250 .and_then(|v| v.as_str())
251 .unwrap_or("client");
252 let tone = args
253 .get("tone")
254 .and_then(|v| v.as_str())
255 .unwrap_or("formal");
256 let highlights = args
257 .get("highlights")
258 .and_then(|v| v.as_str())
259 .filter(|s| !s.trim().is_empty())
260 .ok_or_else(|| {
261 ::zeroclaw_log::record!(
262 WARN,
263 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
264 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
265 .with_attrs(::serde_json::json!({
266 "action": "draft_update",
267 "param": "highlights",
268 })),
269 "project_intel: draft_update missing highlights"
270 );
271 anyhow::Error::msg("missing required 'highlights' for draft_update")
272 })?;
273 let concerns = args.get("concerns").and_then(|v| v.as_str()).unwrap_or("");
274
275 let greeting = match (audience, tone) {
276 ("client", "casual") => "Hi there,".to_string(),
277 ("client", _) => "Dear valued partner,".to_string(),
278 ("internal", "casual") => "Hey team,".to_string(),
279 ("internal", _) => "Dear team,".to_string(),
280 (_, "casual") => "Hi,".to_string(),
281 _ => "Dear reader,".to_string(),
282 };
283
284 let closing = match tone {
285 "casual" => "Cheers",
286 _ => "Best regards",
287 };
288
289 let mut body = format!(
290 "{greeting}\n\nHere is an update on {project_name}.\n\n**Highlights:**\n{highlights}"
291 );
292 if !concerns.is_empty() {
293 let _ = write!(body, "\n\n**Items requiring attention:**\n{concerns}");
294 }
295 let _ = write!(
296 body,
297 "\n\nPlease do not hesitate to reach out with any questions.\n\n{closing}"
298 );
299
300 Ok(ToolResult {
301 success: true,
302 output: body,
303 error: None,
304 })
305 }
306
307 fn execute_sprint_summary(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
308 let sprint_dates = args
309 .get("sprint_dates")
310 .and_then(|v| v.as_str())
311 .unwrap_or("current sprint");
312 let completed = args
313 .get("completed")
314 .and_then(|v| v.as_str())
315 .unwrap_or("None specified");
316 let in_progress = args
317 .get("in_progress")
318 .and_then(|v| v.as_str())
319 .unwrap_or("None specified");
320 let blocked = args
321 .get("blocked")
322 .and_then(|v| v.as_str())
323 .unwrap_or("None");
324 let velocity = args
325 .get("velocity")
326 .and_then(|v| v.as_str())
327 .unwrap_or("Not calculated");
328 let lang = args
329 .get("language")
330 .and_then(|v| v.as_str())
331 .unwrap_or(&self.default_language);
332
333 let tpl = report_templates::sprint_review_template(lang);
334 let mut vars = HashMap::new();
335 vars.insert("sprint_dates".into(), sprint_dates.to_string());
336 vars.insert("completed".into(), completed.to_string());
337 vars.insert("in_progress".into(), in_progress.to_string());
338 vars.insert("blocked".into(), blocked.to_string());
339 vars.insert("velocity".into(), velocity.to_string());
340
341 Ok(ToolResult {
342 success: true,
343 output: tpl.render(&vars),
344 error: None,
345 })
346 }
347
348 fn execute_effort_estimate(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
349 let tasks = args.get("tasks").and_then(|v| v.as_str()).unwrap_or("");
350
351 if tasks.trim().is_empty() {
352 return Ok(ToolResult {
353 success: false,
354 output: String::new(),
355 error: Some("No task descriptions provided".into()),
356 });
357 }
358
359 let mut estimates = Vec::new();
360 for line in tasks.lines() {
361 let line = line.trim();
362 if line.is_empty() {
363 continue;
364 }
365 let (size, rationale) = estimate_task_effort(line);
366 estimates.push(format!("- **{size}** | {line}\n Rationale: {rationale}"));
367 }
368
369 let output = format!(
370 "## Effort Estimates\n\n{}\n\n_Sizes: XS (<2h), S (2-4h), M (4-8h), L (1-3d), XL (3-5d), XXL (>5d)_",
371 estimates.join("\n")
372 );
373
374 Ok(ToolResult {
375 success: true,
376 output,
377 error: None,
378 })
379 }
380}
381
382struct RiskItem {
383 title: String,
384 severity: String,
385 detail: String,
386 mitigation: String,
387}
388
389fn estimate_task_effort(description: &str) -> (&'static str, &'static str) {
391 let lower = description.to_lowercase();
392 let word_count = description.split_whitespace().count();
393
394 let complexity_signals = [
396 "refactor",
397 "rewrite",
398 "migrate",
399 "redesign",
400 "architecture",
401 "infrastructure",
402 ];
403 let medium_signals = [
404 "implement",
405 "create",
406 "build",
407 "integrate",
408 "add feature",
409 "new module",
410 ];
411 let small_signals = [
412 "fix", "update", "tweak", "adjust", "rename", "typo", "bump", "config",
413 ];
414
415 if complexity_signals.iter().any(|s| lower.contains(s)) {
416 if word_count > 15 {
417 return (
418 "XXL",
419 "Large-scope structural change with extensive description",
420 );
421 }
422 return ("XL", "Structural change requiring significant effort");
423 }
424
425 if medium_signals.iter().any(|s| lower.contains(s)) {
426 if word_count > 12 {
427 return ("L", "Feature implementation with detailed requirements");
428 }
429 return ("M", "Standard feature implementation");
430 }
431
432 if small_signals.iter().any(|s| lower.contains(s)) {
433 if word_count > 10 {
434 return ("S", "Small change with additional context");
435 }
436 return ("XS", "Minor targeted change");
437 }
438
439 if word_count > 20 {
441 ("L", "Complex task inferred from detailed description")
442 } else if word_count > 10 {
443 ("M", "Moderate task inferred from description length")
444 } else {
445 ("S", "Simple task inferred from brief description")
446 }
447}
448
449#[async_trait]
450impl Tool for ProjectIntelTool {
451 fn name(&self) -> &str {
452 "project_intel"
453 }
454
455 fn description(&self) -> &str {
456 "Project delivery intelligence: generate status reports, detect risks, draft client updates, summarize sprints, and estimate effort. Read-only analysis tool."
457 }
458
459 fn parameters_schema(&self) -> serde_json::Value {
460 json!({
461 "type": "object",
462 "properties": {
463 "action": {
464 "type": "string",
465 "enum": ["status_report", "risk_scan", "draft_update", "sprint_summary", "effort_estimate"],
466 "description": "The analysis action to perform"
467 },
468 "project_name": {
469 "type": "string",
470 "description": "Project name (for status_report, risk_scan, draft_update)"
471 },
472 "period": {
473 "type": "string",
474 "description": "Reporting period: week, sprint, or month (for status_report)"
475 },
476 "language": {
477 "type": "string",
478 "description": "Report language: en, de, fr, it (default from config)"
479 },
480 "git_log": {
481 "type": "string",
482 "description": "Git log summary text (for status_report)"
483 },
484 "jira_summary": {
485 "type": "string",
486 "description": "Jira/issue tracker summary (for status_report)"
487 },
488 "notes": {
489 "type": "string",
490 "description": "Additional notes or context"
491 },
492 "deadlines": {
493 "type": "string",
494 "description": "Deadline information (for risk_scan)"
495 },
496 "velocity": {
497 "type": "string",
498 "description": "Team velocity data (for risk_scan, sprint_summary)"
499 },
500 "blockers": {
501 "type": "string",
502 "description": "Current blockers (for risk_scan)"
503 },
504 "audience": {
505 "type": "string",
506 "enum": ["client", "internal"],
507 "description": "Target audience (for draft_update)"
508 },
509 "tone": {
510 "type": "string",
511 "enum": ["formal", "casual"],
512 "description": "Communication tone (for draft_update)"
513 },
514 "highlights": {
515 "type": "string",
516 "description": "Key highlights for the update (for draft_update)"
517 },
518 "concerns": {
519 "type": "string",
520 "description": "Items requiring attention (for draft_update)"
521 },
522 "sprint_dates": {
523 "type": "string",
524 "description": "Sprint date range (for sprint_summary)"
525 },
526 "completed": {
527 "type": "string",
528 "description": "Completed items (for sprint_summary)"
529 },
530 "in_progress": {
531 "type": "string",
532 "description": "In-progress items (for sprint_summary)"
533 },
534 "blocked": {
535 "type": "string",
536 "description": "Blocked items (for sprint_summary)"
537 },
538 "tasks": {
539 "type": "string",
540 "description": "Task descriptions, one per line (for effort_estimate)"
541 }
542 },
543 "required": ["action"]
544 })
545 }
546
547 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
548 let action = args.get("action").and_then(|v| v.as_str()).ok_or_else(|| {
549 ::zeroclaw_log::record!(
550 WARN,
551 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
552 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
553 .with_attrs(::serde_json::json!({"param": "action"})),
554 "project_intel: missing action parameter"
555 );
556 anyhow::Error::msg("Missing required 'action' parameter")
557 })?;
558
559 match action {
560 "status_report" => self.execute_status_report(&args),
561 "risk_scan" => self.execute_risk_scan(&args),
562 "draft_update" => self.execute_draft_update(&args),
563 "sprint_summary" => self.execute_sprint_summary(&args),
564 "effort_estimate" => self.execute_effort_estimate(&args),
565 other => Ok(ToolResult {
566 success: false,
567 output: String::new(),
568 error: Some(format!(
569 "Unknown action '{other}'. Valid actions: status_report, risk_scan, draft_update, sprint_summary, effort_estimate"
570 )),
571 }),
572 }
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 fn tool() -> ProjectIntelTool {
581 ProjectIntelTool::new("en".into(), "medium".into())
582 }
583
584 #[test]
585 fn tool_name_and_description() {
586 let t = tool();
587 assert_eq!(t.name(), "project_intel");
588 assert!(!t.description().is_empty());
589 }
590
591 #[test]
592 fn parameters_schema_has_action() {
593 let t = tool();
594 let schema = t.parameters_schema();
595 assert!(schema["properties"]["action"].is_object());
596 let required = schema["required"].as_array().unwrap();
597 assert!(required.contains(&serde_json::Value::String("action".into())));
598 }
599
600 #[tokio::test]
601 async fn status_report_renders() {
602 let t = tool();
603 let result = t
604 .execute(json!({
605 "action": "status_report",
606 "project_name": "TestProject",
607 "period": "week",
608 "git_log": "- feat: added login"
609 }))
610 .await
611 .unwrap();
612 assert!(result.success);
613 assert!(result.output.contains("TestProject"));
614 assert!(result.output.contains("added login"));
615 }
616
617 #[tokio::test]
618 async fn risk_scan_detects_blockers() {
619 let t = tool();
620 let result = t
621 .execute(json!({
622 "action": "risk_scan",
623 "blockers": "DB migration stuck\nCI pipeline broken\nAPI key expired"
624 }))
625 .await
626 .unwrap();
627 assert!(result.success);
628 assert!(result.output.contains("blocker"));
629 }
630
631 #[tokio::test]
632 async fn risk_scan_detects_deadline_risk() {
633 let t = tool();
634 let result = t
635 .execute(json!({
636 "action": "risk_scan",
637 "deadlines": "Sprint deadline overdue by 3 days"
638 }))
639 .await
640 .unwrap();
641 assert!(result.success);
642 assert!(result.output.contains("Deadline risk"));
643 }
644
645 #[tokio::test]
646 async fn risk_scan_no_signals_returns_low_risk() {
647 let t = tool();
648 let result = t.execute(json!({ "action": "risk_scan" })).await.unwrap();
649 assert!(result.success);
650 assert!(result.output.contains("No significant risks"));
651 }
652
653 #[tokio::test]
654 async fn draft_update_formal_client() {
655 let t = tool();
656 let result = t
657 .execute(json!({
658 "action": "draft_update",
659 "project_name": "Portal",
660 "audience": "client",
661 "tone": "formal",
662 "highlights": "Phase 1 delivered"
663 }))
664 .await
665 .unwrap();
666 assert!(result.success);
667 assert!(result.output.contains("Dear valued partner"));
668 assert!(result.output.contains("Portal"));
669 assert!(result.output.contains("Phase 1 delivered"));
670 }
671
672 #[tokio::test]
673 async fn draft_update_casual_internal() {
674 let t = tool();
675 let result = t
676 .execute(json!({
677 "action": "draft_update",
678 "project_name": "ZeroClaw",
679 "audience": "internal",
680 "tone": "casual",
681 "highlights": "Core loop stabilized"
682 }))
683 .await
684 .unwrap();
685 assert!(result.success);
686 assert!(result.output.contains("Hey team"));
687 assert!(result.output.contains("Cheers"));
688 }
689
690 #[tokio::test]
691 async fn sprint_summary_renders() {
692 let t = tool();
693 let result = t
694 .execute(json!({
695 "action": "sprint_summary",
696 "sprint_dates": "2026-03-01 to 2026-03-14",
697 "completed": "- Login page\n- API endpoints",
698 "in_progress": "- Dashboard",
699 "blocked": "- Payment integration"
700 }))
701 .await
702 .unwrap();
703 assert!(result.success);
704 assert!(result.output.contains("Login page"));
705 assert!(result.output.contains("Dashboard"));
706 }
707
708 #[tokio::test]
709 async fn effort_estimate_basic() {
710 let t = tool();
711 let result = t
712 .execute(json!({
713 "action": "effort_estimate",
714 "tasks": "Fix typo in README\nImplement user authentication\nRefactor database layer"
715 }))
716 .await
717 .unwrap();
718 assert!(result.success);
719 assert!(result.output.contains("XS"));
720 assert!(result.output.contains("Refactor database layer"));
721 }
722
723 #[tokio::test]
724 async fn effort_estimate_empty_tasks_fails() {
725 let t = tool();
726 let result = t
727 .execute(json!({ "action": "effort_estimate", "tasks": "" }))
728 .await
729 .unwrap();
730 assert!(!result.success);
731 assert!(result.error.unwrap().contains("No task descriptions"));
732 }
733
734 #[tokio::test]
735 async fn unknown_action_returns_error() {
736 let t = tool();
737 let result = t
738 .execute(json!({ "action": "invalid_thing" }))
739 .await
740 .unwrap();
741 assert!(!result.success);
742 assert!(result.error.unwrap().contains("Unknown action"));
743 }
744
745 #[tokio::test]
746 async fn missing_action_returns_error() {
747 let t = tool();
748 let result = t.execute(json!({})).await;
749 assert!(result.is_err());
750 }
751
752 #[test]
753 fn effort_estimate_heuristics_coverage() {
754 assert_eq!(estimate_task_effort("Fix typo").0, "XS");
755 assert_eq!(estimate_task_effort("Update config values").0, "XS");
756 assert_eq!(
757 estimate_task_effort("Implement new notification system").0,
758 "M"
759 );
760 assert_eq!(
761 estimate_task_effort("Refactor the entire authentication module").0,
762 "XL"
763 );
764 assert_eq!(
765 estimate_task_effort("Migrate the database schema to support multi-tenancy with data isolation and proper indexing across all services").0,
766 "XXL"
767 );
768 }
769
770 #[test]
771 fn risk_sensitivity_threshold_ordering() {
772 assert!(
773 RiskSensitivity::High.threshold_factor() < RiskSensitivity::Medium.threshold_factor()
774 );
775 assert!(
776 RiskSensitivity::Medium.threshold_factor() < RiskSensitivity::Low.threshold_factor()
777 );
778 }
779
780 #[test]
781 fn risk_sensitivity_from_str_variants() {
782 assert_eq!(RiskSensitivity::from_str("low"), RiskSensitivity::Low);
783 assert_eq!(RiskSensitivity::from_str("high"), RiskSensitivity::High);
784 assert_eq!(RiskSensitivity::from_str("medium"), RiskSensitivity::Medium);
785 assert_eq!(
786 RiskSensitivity::from_str("unknown"),
787 RiskSensitivity::Medium
788 );
789 }
790
791 #[tokio::test]
792 async fn high_sensitivity_detects_single_blocker_as_high() {
793 let t = ProjectIntelTool::new("en".into(), "high".into());
794 let result = t
795 .execute(json!({
796 "action": "risk_scan",
797 "blockers": "Single blocker"
798 }))
799 .await
800 .unwrap();
801 assert!(result.success);
802 assert!(result.output.contains("[HIGH]") || result.output.contains("[CRITICAL]"));
803 }
804}