Skip to main content

utf8proj_render/
mermaid.rs

1//! MermaidJS Gantt chart renderer
2//!
3//! Generates text-based Gantt charts in MermaidJS format, suitable for
4//! embedding in Markdown documentation, GitHub, wikis, and other platforms.
5//!
6//! ## Example Output
7//!
8//! ```text
9//! gantt
10//!     title Project Name
11//!     dateFormat YYYY-MM-DD
12//!
13//!     section Phase 1
14//!     Design           :crit, t1, 2025-01-06, 5d
15//!     Implementation   :t2, after t1, 10d
16//!
17//!     section Phase 2
18//!     Testing          :t3, after t2, 3d
19//!     Deployment       :milestone, m1, after t3, 0d
20//! ```
21
22use crate::DisplayMode;
23use utf8proj_core::{Project, RenderError, Renderer, Schedule, ScheduledTask};
24
25/// MermaidJS Gantt chart renderer
26#[derive(Clone, Debug)]
27pub struct MermaidRenderer {
28    /// Whether to show sections (group by parent task)
29    pub show_sections: bool,
30    /// Whether to mark critical path tasks
31    pub show_critical: bool,
32    /// Whether to show task completion status
33    pub show_completion: bool,
34    /// Date format (MermaidJS format string)
35    pub date_format: String,
36    /// Whether to use `after` syntax for dependencies
37    pub use_dependencies: bool,
38    /// Exclude weekends from duration calculation
39    pub exclude_weekends: bool,
40    /// Display mode for task labels
41    pub display_mode: DisplayMode,
42    /// Maximum label width in characters
43    pub label_width: usize,
44}
45
46impl Default for MermaidRenderer {
47    fn default() -> Self {
48        Self {
49            show_sections: true,
50            show_critical: true,
51            show_completion: true,
52            date_format: "YYYY-MM-DD".into(),
53            use_dependencies: true,
54            exclude_weekends: false,
55            display_mode: DisplayMode::Name,
56            label_width: 40,
57        }
58    }
59}
60
61impl MermaidRenderer {
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// Disable sections grouping
67    pub fn no_sections(mut self) -> Self {
68        self.show_sections = false;
69        self
70    }
71
72    /// Disable critical path highlighting
73    pub fn no_critical(mut self) -> Self {
74        self.show_critical = false;
75        self
76    }
77
78    /// Disable completion status
79    pub fn no_completion(mut self) -> Self {
80        self.show_completion = false;
81        self
82    }
83
84    /// Use absolute dates instead of `after` dependencies
85    pub fn absolute_dates(mut self) -> Self {
86        self.use_dependencies = false;
87        self
88    }
89
90    /// Set custom date format
91    pub fn date_format(mut self, format: impl Into<String>) -> Self {
92        self.date_format = format.into();
93        self
94    }
95
96    /// Exclude weekends (use excludes directive)
97    pub fn exclude_weekends(mut self) -> Self {
98        self.exclude_weekends = true;
99        self
100    }
101
102    /// Configure display mode for task labels
103    pub fn display_mode(mut self, mode: DisplayMode) -> Self {
104        self.display_mode = mode;
105        self
106    }
107
108    /// Configure maximum label width in characters
109    pub fn label_width(mut self, width: usize) -> Self {
110        self.label_width = width;
111        self
112    }
113
114    /// Sanitize task name for Mermaid (escape special characters)
115    fn sanitize_name(name: &str) -> String {
116        // Mermaid is sensitive to colons and special chars in task names
117        name.replace(':', "-")
118            .replace(';', "-")
119            .replace('#', "")
120            .replace('\n', " ")
121            .replace('\r', "")
122    }
123
124    /// Create a valid Mermaid task ID from task_id
125    fn make_id(task_id: &str) -> String {
126        // Mermaid IDs must be alphanumeric with underscores
127        task_id
128            .chars()
129            .map(|c| {
130                if c.is_alphanumeric() || c == '_' {
131                    c
132                } else {
133                    '_'
134                }
135            })
136            .collect()
137    }
138
139    /// Format duration for Mermaid
140    fn format_duration(task: &ScheduledTask) -> String {
141        let days = task.duration.as_days().ceil() as i64;
142        if days == 0 {
143            "0d".into()
144        } else {
145            format!("{}d", days)
146        }
147    }
148
149    /// Get task modifiers (crit, done, active, milestone)
150    fn get_modifiers(&self, task: &ScheduledTask, complete: Option<f32>) -> Vec<&'static str> {
151        let mut mods = Vec::new();
152
153        // Check if milestone
154        if task.duration.minutes == 0 {
155            mods.push("milestone");
156        }
157
158        // Critical path
159        if self.show_critical && task.is_critical {
160            mods.push("crit");
161        }
162
163        // Completion status
164        if self.show_completion {
165            if let Some(pct) = complete {
166                if pct >= 100.0 {
167                    mods.push("done");
168                } else if pct > 0.0 {
169                    mods.push("active");
170                }
171            }
172        }
173
174        mods
175    }
176}
177
178impl Renderer for MermaidRenderer {
179    type Output = String;
180
181    fn render(&self, project: &Project, schedule: &Schedule) -> Result<String, RenderError> {
182        if schedule.tasks.is_empty() {
183            return Err(RenderError::InvalidData("No tasks to render".into()));
184        }
185
186        let mut output = String::new();
187
188        // Header
189        output.push_str("gantt\n");
190        output.push_str(&format!(
191            "    title {}\n",
192            Self::sanitize_name(&project.name)
193        ));
194        output.push_str(&format!("    dateFormat {}\n", self.date_format));
195
196        // Exclude weekends if enabled
197        if self.exclude_weekends {
198            output.push_str("    excludes weekends\n");
199        }
200
201        output.push('\n');
202
203        // Sort tasks by start date
204        let mut tasks: Vec<(&String, &ScheduledTask)> = schedule.tasks.iter().collect();
205        tasks.sort_by_key(|(_, t)| t.start);
206
207        // Build dependency map (task_id -> first predecessor)
208        let mut first_predecessor: std::collections::HashMap<String, String> =
209            std::collections::HashMap::new();
210        for task in &project.tasks {
211            self.collect_predecessors(task, &mut first_predecessor);
212        }
213
214        // Group by section if enabled
215        if self.show_sections {
216            // Group tasks by their parent (first part of qualified ID)
217            let mut sections: std::collections::HashMap<String, Vec<(&String, &ScheduledTask)>> =
218                std::collections::HashMap::new();
219
220            for (task_id, scheduled) in &tasks {
221                let section = if task_id.contains('.') {
222                    task_id.split('.').next().unwrap_or("Tasks").to_string()
223                } else {
224                    "Tasks".to_string()
225                };
226                sections
227                    .entry(section)
228                    .or_default()
229                    .push((task_id, scheduled));
230            }
231
232            // Sort sections
233            let mut section_names: Vec<_> = sections.keys().cloned().collect();
234            section_names.sort();
235
236            for section_name in section_names {
237                if let Some(section_tasks) = sections.get(&section_name) {
238                    // Get section display name from project
239                    // Extract leaf task ID from full path for lookup
240                    let leaf_section_id = section_name.rsplit('.').next().unwrap_or(&section_name);
241                    let display_name = project
242                        .get_task(leaf_section_id)
243                        .map(|t| t.name.clone())
244                        .unwrap_or_else(|| section_name.clone());
245
246                    output.push_str(&format!(
247                        "    section {}\n",
248                        Self::sanitize_name(&display_name)
249                    ));
250
251                    for (task_id, scheduled) in section_tasks {
252                        let line =
253                            self.format_task_line(task_id, scheduled, project, &first_predecessor);
254                        output.push_str(&format!("    {}\n", line));
255                    }
256                    output.push('\n');
257                }
258            }
259        } else {
260            // No sections - flat list
261            for (task_id, scheduled) in &tasks {
262                let line = self.format_task_line(task_id, scheduled, project, &first_predecessor);
263                output.push_str(&format!("    {}\n", line));
264            }
265        }
266
267        Ok(output)
268    }
269}
270
271impl MermaidRenderer {
272    /// Collect first predecessor for each task
273    fn collect_predecessors(
274        &self,
275        task: &utf8proj_core::Task,
276        map: &mut std::collections::HashMap<String, String>,
277    ) {
278        if let Some(first_dep) = task.depends.first() {
279            map.insert(task.id.clone(), first_dep.predecessor.clone());
280        }
281        for child in &task.children {
282            self.collect_predecessors(child, map);
283        }
284    }
285
286    /// Format a single task line
287    fn format_task_line(
288        &self,
289        task_id: &str,
290        scheduled: &ScheduledTask,
291        project: &Project,
292        first_predecessor: &std::collections::HashMap<String, String>,
293    ) -> String {
294        // Get task info from project
295        // Extract leaf task ID from full path (e.g., "task_2007.task_2014.task_2250" -> "task_2250")
296        let leaf_id = task_id.rsplit('.').next().unwrap_or(task_id);
297        let task = project.get_task(leaf_id);
298        let name = task
299            .map(|t| t.name.clone())
300            .unwrap_or_else(|| task_id.to_string());
301        let complete = task.and_then(|t| t.complete);
302
303        // Format label according to display mode
304        let label = self
305            .display_mode
306            .format_label(task_id, &name, self.label_width);
307        let sanitized_name = Self::sanitize_name(&label);
308        let mermaid_id = Self::make_id(task_id);
309        let duration = Self::format_duration(scheduled);
310        let modifiers = self.get_modifiers(scheduled, complete);
311
312        // Build the task specification
313        let mut parts = Vec::new();
314
315        // Add modifiers (crit, done, active, milestone)
316        for m in &modifiers {
317            parts.push(m.to_string());
318        }
319
320        // Add task ID
321        parts.push(mermaid_id.clone());
322
323        // Add start (either "after X" or absolute date)
324        if self.use_dependencies {
325            if let Some(pred) = first_predecessor.get(task_id) {
326                parts.push(format!("after {}", Self::make_id(pred)));
327            } else {
328                parts.push(scheduled.start.format("%Y-%m-%d").to_string());
329            }
330        } else {
331            parts.push(scheduled.start.format("%Y-%m-%d").to_string());
332        }
333
334        // Add duration
335        parts.push(duration);
336
337        format!("{} :{}", sanitized_name, parts.join(", "))
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use chrono::NaiveDate;
345    use std::collections::HashMap;
346    use utf8proj_core::{Duration, Schedule, ScheduledTask, Task, TaskStatus};
347
348    fn create_test_project() -> Project {
349        let mut project = Project::new("Test Project");
350        project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
351        project.tasks.push(
352            Task::new("design")
353                .name("Design Phase")
354                .effort(Duration::days(5)),
355        );
356        project.tasks.push(
357            Task::new("implement")
358                .name("Implementation")
359                .effort(Duration::days(10))
360                .depends_on("design"),
361        );
362        project.tasks.push(
363            Task::new("test")
364                .name("Testing")
365                .effort(Duration::days(3))
366                .depends_on("implement"),
367        );
368        project
369    }
370
371    fn create_test_schedule() -> Schedule {
372        let mut tasks = HashMap::new();
373
374        let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
375        let finish1 = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
376        tasks.insert(
377            "design".to_string(),
378            ScheduledTask {
379                task_id: "design".to_string(),
380                start: start1,
381                finish: finish1,
382                duration: Duration::days(5),
383                assignments: vec![],
384                slack: Duration::zero(),
385                is_critical: true,
386                early_start: start1,
387                early_finish: finish1,
388                late_start: start1,
389                late_finish: finish1,
390                forecast_start: start1,
391                forecast_finish: finish1,
392                remaining_duration: Duration::days(5),
393                percent_complete: 0,
394                status: TaskStatus::NotStarted,
395                cost_range: None,
396                has_abstract_assignments: false,
397                baseline_start: start1,
398                baseline_finish: finish1,
399                start_variance_days: 0,
400                finish_variance_days: 0,
401            },
402        );
403
404        let start2 = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
405        let finish2 = NaiveDate::from_ymd_opt(2025, 1, 24).unwrap();
406        tasks.insert(
407            "implement".to_string(),
408            ScheduledTask {
409                task_id: "implement".to_string(),
410                start: start2,
411                finish: finish2,
412                duration: Duration::days(10),
413                assignments: vec![],
414                slack: Duration::zero(),
415                is_critical: true,
416                early_start: start2,
417                early_finish: finish2,
418                late_start: start2,
419                late_finish: finish2,
420                forecast_start: start2,
421                forecast_finish: finish2,
422                remaining_duration: Duration::days(10),
423                percent_complete: 0,
424                status: TaskStatus::NotStarted,
425                cost_range: None,
426                has_abstract_assignments: false,
427                baseline_start: start2,
428                baseline_finish: finish2,
429                start_variance_days: 0,
430                finish_variance_days: 0,
431            },
432        );
433
434        let start3 = NaiveDate::from_ymd_opt(2025, 1, 27).unwrap();
435        let finish3 = NaiveDate::from_ymd_opt(2025, 1, 29).unwrap();
436        tasks.insert(
437            "test".to_string(),
438            ScheduledTask {
439                task_id: "test".to_string(),
440                start: start3,
441                finish: finish3,
442                duration: Duration::days(3),
443                assignments: vec![],
444                slack: Duration::zero(),
445                is_critical: true,
446                early_start: start3,
447                early_finish: finish3,
448                late_start: start3,
449                late_finish: finish3,
450                forecast_start: start3,
451                forecast_finish: finish3,
452                remaining_duration: Duration::days(3),
453                percent_complete: 0,
454                status: TaskStatus::NotStarted,
455                cost_range: None,
456                has_abstract_assignments: false,
457                baseline_start: start3,
458                baseline_finish: finish3,
459                start_variance_days: 0,
460                finish_variance_days: 0,
461            },
462        );
463
464        let project_end = NaiveDate::from_ymd_opt(2025, 1, 29).unwrap();
465        Schedule {
466            tasks,
467            critical_path: vec![
468                "design".to_string(),
469                "implement".to_string(),
470                "test".to_string(),
471            ],
472            project_duration: Duration::days(18),
473            project_end,
474            total_cost: None,
475            total_cost_range: None,
476            project_progress: 0,
477            project_baseline_finish: project_end,
478            project_forecast_finish: project_end,
479            project_variance_days: 0,
480            planned_value: 0,
481            earned_value: 0,
482            spi: 1.0,
483        }
484    }
485
486    #[test]
487    fn mermaid_renderer_creation() {
488        let renderer = MermaidRenderer::new();
489        assert!(renderer.show_sections);
490        assert!(renderer.show_critical);
491        assert_eq!(renderer.date_format, "YYYY-MM-DD");
492    }
493
494    #[test]
495    fn mermaid_renderer_with_options() {
496        let renderer = MermaidRenderer::new()
497            .no_sections()
498            .no_critical()
499            .absolute_dates()
500            .exclude_weekends();
501
502        assert!(!renderer.show_sections);
503        assert!(!renderer.show_critical);
504        assert!(!renderer.use_dependencies);
505        assert!(renderer.exclude_weekends);
506    }
507
508    #[test]
509    fn mermaid_produces_valid_output() {
510        let renderer = MermaidRenderer::new();
511        let project = create_test_project();
512        let schedule = create_test_schedule();
513
514        let result = renderer.render(&project, &schedule);
515        assert!(result.is_ok());
516
517        let output = result.unwrap();
518        assert!(output.starts_with("gantt\n"));
519        assert!(output.contains("title Test Project"));
520        assert!(output.contains("dateFormat YYYY-MM-DD"));
521    }
522
523    #[test]
524    fn mermaid_includes_critical_marker() {
525        let renderer = MermaidRenderer::new();
526        let project = create_test_project();
527        let schedule = create_test_schedule();
528
529        let output = renderer.render(&project, &schedule).unwrap();
530        assert!(output.contains("crit"));
531    }
532
533    #[test]
534    fn mermaid_uses_after_syntax() {
535        let renderer = MermaidRenderer::new();
536        let project = create_test_project();
537        let schedule = create_test_schedule();
538
539        let output = renderer.render(&project, &schedule).unwrap();
540        assert!(output.contains("after design"));
541        assert!(output.contains("after implement"));
542    }
543
544    #[test]
545    fn mermaid_absolute_dates_mode() {
546        let renderer = MermaidRenderer::new().absolute_dates();
547        let project = create_test_project();
548        let schedule = create_test_schedule();
549
550        let output = renderer.render(&project, &schedule).unwrap();
551        assert!(!output.contains("after "));
552        assert!(output.contains("2025-01-06"));
553        assert!(output.contains("2025-01-13"));
554    }
555
556    #[test]
557    fn mermaid_empty_schedule_fails() {
558        let renderer = MermaidRenderer::new();
559        let project = Project::new("Empty");
560        let project_end = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
561        let schedule = Schedule {
562            tasks: HashMap::new(),
563            critical_path: vec![],
564            project_duration: Duration::zero(),
565            project_end,
566            total_cost: None,
567            total_cost_range: None,
568            project_progress: 0,
569            project_baseline_finish: project_end,
570            project_forecast_finish: project_end,
571            project_variance_days: 0,
572            planned_value: 0,
573            earned_value: 0,
574            spi: 1.0,
575        };
576
577        let result = renderer.render(&project, &schedule);
578        assert!(result.is_err());
579    }
580
581    #[test]
582    fn mermaid_sanitizes_special_chars() {
583        assert_eq!(
584            MermaidRenderer::sanitize_name("Task: Phase 1"),
585            "Task- Phase 1"
586        );
587        assert_eq!(MermaidRenderer::sanitize_name("Test;Task"), "Test-Task");
588        assert_eq!(MermaidRenderer::sanitize_name("Task #1"), "Task 1");
589    }
590
591    #[test]
592    fn mermaid_makes_valid_ids() {
593        assert_eq!(MermaidRenderer::make_id("task1"), "task1");
594        assert_eq!(MermaidRenderer::make_id("phase1.design"), "phase1_design");
595        assert_eq!(
596            MermaidRenderer::make_id("task-with-dashes"),
597            "task_with_dashes"
598        );
599    }
600
601    #[test]
602    fn mermaid_excludes_weekends() {
603        let renderer = MermaidRenderer::new().exclude_weekends();
604        let project = create_test_project();
605        let schedule = create_test_schedule();
606
607        let output = renderer.render(&project, &schedule).unwrap();
608        assert!(output.contains("excludes weekends"));
609    }
610
611    #[test]
612    fn mermaid_milestone_detection() {
613        let mut project = Project::new("Milestone Test");
614        project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
615        project
616            .tasks
617            .push(Task::new("done").name("Project Complete").milestone());
618
619        let ms_date = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
620        let mut tasks = HashMap::new();
621        tasks.insert(
622            "done".to_string(),
623            ScheduledTask {
624                task_id: "done".to_string(),
625                start: ms_date,
626                finish: ms_date,
627                duration: Duration::zero(),
628                assignments: vec![],
629                slack: Duration::zero(),
630                is_critical: true,
631                early_start: ms_date,
632                early_finish: ms_date,
633                late_start: ms_date,
634                late_finish: ms_date,
635                forecast_start: ms_date,
636                forecast_finish: ms_date,
637                remaining_duration: Duration::zero(),
638                percent_complete: 0,
639                status: TaskStatus::NotStarted,
640                cost_range: None,
641                has_abstract_assignments: false,
642                baseline_start: ms_date,
643                baseline_finish: ms_date,
644                start_variance_days: 0,
645                finish_variance_days: 0,
646            },
647        );
648
649        let project_end = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
650        let schedule = Schedule {
651            tasks,
652            critical_path: vec!["done".to_string()],
653            project_duration: Duration::zero(),
654            project_end,
655            total_cost: None,
656            total_cost_range: None,
657            project_progress: 0,
658            project_baseline_finish: project_end,
659            project_forecast_finish: project_end,
660            project_variance_days: 0,
661            planned_value: 0,
662            earned_value: 0,
663            spi: 1.0,
664        };
665
666        let renderer = MermaidRenderer::new();
667        let output = renderer.render(&project, &schedule).unwrap();
668        assert!(output.contains("milestone"));
669    }
670
671    #[test]
672    fn mermaid_no_completion_option() {
673        let renderer = MermaidRenderer::new().no_completion();
674        assert!(!renderer.show_completion);
675    }
676
677    #[test]
678    fn mermaid_custom_date_format() {
679        let renderer = MermaidRenderer::new().date_format("DD-MM-YYYY");
680        assert_eq!(renderer.date_format, "DD-MM-YYYY");
681
682        let project = create_test_project();
683        let schedule = create_test_schedule();
684        let output = renderer.render(&project, &schedule).unwrap();
685        assert!(output.contains("dateFormat DD-MM-YYYY"));
686    }
687
688    #[test]
689    fn mermaid_no_sections_flat_list() {
690        let renderer = MermaidRenderer::new().no_sections();
691        let project = create_test_project();
692        let schedule = create_test_schedule();
693
694        let output = renderer.render(&project, &schedule).unwrap();
695        // Without sections, output should not contain "section" directive
696        assert!(!output.contains("section "));
697    }
698
699    #[test]
700    fn mermaid_done_modifier_for_complete_task() {
701        let mut project = Project::new("Progress Test");
702        project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
703        project.tasks.push(
704            Task::new("complete")
705                .name("Completed Task")
706                .effort(Duration::days(5))
707                .complete(100.0),
708        );
709
710        let start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
711        let finish = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
712        let mut tasks = HashMap::new();
713        tasks.insert(
714            "complete".to_string(),
715            ScheduledTask {
716                task_id: "complete".to_string(),
717                start,
718                finish,
719                duration: Duration::days(5),
720                assignments: vec![],
721                slack: Duration::zero(),
722                is_critical: true,
723                early_start: start,
724                early_finish: finish,
725                late_start: start,
726                late_finish: finish,
727                forecast_start: start,
728                forecast_finish: finish,
729                remaining_duration: Duration::zero(),
730                percent_complete: 100,
731                status: TaskStatus::Complete,
732                cost_range: None,
733                has_abstract_assignments: false,
734                baseline_start: start,
735                baseline_finish: finish,
736                start_variance_days: 0,
737                finish_variance_days: 0,
738            },
739        );
740
741        let schedule = Schedule {
742            tasks,
743            critical_path: vec!["complete".to_string()],
744            project_duration: Duration::days(5),
745            project_end: finish,
746            total_cost: None,
747            total_cost_range: None,
748            project_progress: 0,
749            project_baseline_finish: finish,
750            project_forecast_finish: finish,
751            project_variance_days: 0,
752            planned_value: 0,
753            earned_value: 0,
754            spi: 1.0,
755        };
756
757        let renderer = MermaidRenderer::new();
758        let output = renderer.render(&project, &schedule).unwrap();
759        assert!(output.contains("done"));
760    }
761
762    #[test]
763    fn mermaid_active_modifier_for_in_progress_task() {
764        let mut project = Project::new("Progress Test");
765        project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
766        project.tasks.push(
767            Task::new("inprogress")
768                .name("In Progress Task")
769                .effort(Duration::days(10))
770                .complete(50.0),
771        );
772
773        let start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
774        let finish = NaiveDate::from_ymd_opt(2025, 1, 17).unwrap();
775        let mut tasks = HashMap::new();
776        tasks.insert(
777            "inprogress".to_string(),
778            ScheduledTask {
779                task_id: "inprogress".to_string(),
780                start,
781                finish,
782                duration: Duration::days(10),
783                assignments: vec![],
784                slack: Duration::zero(),
785                is_critical: true,
786                early_start: start,
787                early_finish: finish,
788                late_start: start,
789                late_finish: finish,
790                forecast_start: start,
791                forecast_finish: finish,
792                remaining_duration: Duration::days(5),
793                percent_complete: 50,
794                status: TaskStatus::InProgress,
795                cost_range: None,
796                has_abstract_assignments: false,
797                baseline_start: start,
798                baseline_finish: finish,
799                start_variance_days: 0,
800                finish_variance_days: 0,
801            },
802        );
803
804        let schedule = Schedule {
805            tasks,
806            critical_path: vec!["inprogress".to_string()],
807            project_duration: Duration::days(10),
808            project_end: finish,
809            total_cost: None,
810            total_cost_range: None,
811            project_progress: 0,
812            project_baseline_finish: finish,
813            project_forecast_finish: finish,
814            project_variance_days: 0,
815            planned_value: 0,
816            earned_value: 0,
817            spi: 1.0,
818        };
819
820        let renderer = MermaidRenderer::new();
821        let output = renderer.render(&project, &schedule).unwrap();
822        assert!(output.contains("active"));
823    }
824
825    #[test]
826    fn mermaid_no_completion_hides_done_active() {
827        let mut project = Project::new("No Completion");
828        project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
829        project.tasks.push(
830            Task::new("task")
831                .name("Task")
832                .effort(Duration::days(5))
833                .complete(100.0),
834        );
835
836        let start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
837        let finish = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
838        let mut tasks = HashMap::new();
839        tasks.insert(
840            "task".to_string(),
841            ScheduledTask {
842                task_id: "task".to_string(),
843                start,
844                finish,
845                duration: Duration::days(5),
846                assignments: vec![],
847                slack: Duration::zero(),
848                is_critical: false,
849                early_start: start,
850                early_finish: finish,
851                late_start: start,
852                late_finish: finish,
853                forecast_start: start,
854                forecast_finish: finish,
855                remaining_duration: Duration::zero(),
856                percent_complete: 100,
857                status: TaskStatus::Complete,
858                cost_range: None,
859                has_abstract_assignments: false,
860                baseline_start: start,
861                baseline_finish: finish,
862                start_variance_days: 0,
863                finish_variance_days: 0,
864            },
865        );
866
867        let schedule = Schedule {
868            tasks,
869            critical_path: vec![],
870            project_duration: Duration::days(5),
871            project_end: finish,
872            total_cost: None,
873            total_cost_range: None,
874            project_progress: 0,
875            project_baseline_finish: finish,
876            project_forecast_finish: finish,
877            project_variance_days: 0,
878            planned_value: 0,
879            earned_value: 0,
880            spi: 1.0,
881        };
882
883        let renderer = MermaidRenderer::new().no_completion().no_critical();
884        let output = renderer.render(&project, &schedule).unwrap();
885        // With no_completion, should not have done or active markers
886        assert!(!output.contains("done"));
887        assert!(!output.contains("active"));
888        assert!(!output.contains("crit"));
889    }
890}