Skip to main content

branchdiff/
patch.rs

1//! Git unified patch format generation.
2//!
3//! Converts branchdiff's DiffLine representation into standard git patch format
4//! suitable for `git apply` or GitHub `.diff` files.
5
6use std::fmt;
7
8use crate::diff::{DiffLine, LineSource};
9
10/// Number of context lines to include around changes in hunks.
11const CONTEXT_LINES: usize = 3;
12
13/// A line in a patch with its prefix character.
14#[derive(Debug, Clone)]
15struct PatchLine {
16    /// Prefix character: ' ' for context, '+' for addition, '-' for deletion
17    prefix: char,
18    /// Line content (without newline)
19    content: String,
20    /// Original line number in the old file (for context and deletions)
21    old_line: Option<usize>,
22    /// Line number in the new file (for context and additions)
23    new_line: Option<usize>,
24}
25
26/// A hunk representing a contiguous section of changes.
27#[derive(Debug)]
28struct Hunk {
29    /// Starting line number in the old file (1-based)
30    old_start: usize,
31    /// Number of lines from the old file in this hunk
32    old_count: usize,
33    /// Starting line number in the new file (1-based)
34    new_start: usize,
35    /// Number of lines from the new file in this hunk
36    new_count: usize,
37    /// Lines in this hunk
38    lines: Vec<PatchLine>,
39}
40
41impl Hunk {
42    /// Formats the hunk header per git spec: count is omitted when it equals 1.
43    fn header(&self) -> String {
44        let old_range = format_range(self.old_start, self.old_count);
45        let new_range = format_range(self.new_start, self.new_count);
46        format!("@@ -{} +{} @@", old_range, new_range)
47    }
48}
49
50/// Formats a line range for hunk headers. Per git spec, count is omitted when 1.
51fn format_range(start: usize, count: usize) -> String {
52    if count == 1 {
53        start.to_string()
54    } else {
55        format!("{},{}", start, count)
56    }
57}
58
59impl fmt::Display for Hunk {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        writeln!(f, "{}", self.header())?;
62        for line in &self.lines {
63            writeln!(f, "{}{}", line.prefix, line.content)?;
64        }
65        Ok(())
66    }
67}
68
69/// A patch for a single file.
70#[derive(Debug)]
71struct FilePatch {
72    path: String,
73    hunks: Vec<Hunk>,
74    is_new_file: bool,
75    is_deleted_file: bool,
76}
77
78impl FilePatch {
79    fn new(path: String, hunks: Vec<Hunk>) -> Self {
80        // Detect new file: all hunks have old_count == 0
81        let is_new_file = !hunks.is_empty() && hunks.iter().all(|h| h.old_count == 0);
82        // Detect deleted file: all hunks have new_count == 0
83        let is_deleted_file = !hunks.is_empty() && hunks.iter().all(|h| h.new_count == 0);
84
85        Self {
86            path,
87            hunks,
88            is_new_file,
89            is_deleted_file,
90        }
91    }
92}
93
94impl fmt::Display for FilePatch {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        if self.hunks.is_empty() {
97            return Ok(());
98        }
99
100        writeln!(f, "diff --git a/{} b/{}", self.path, self.path)?;
101
102        // Per git spec: new files use /dev/null for ---, deleted files use /dev/null for +++
103        if self.is_new_file {
104            writeln!(f, "new file mode 100644")?;
105            writeln!(f, "--- /dev/null")?;
106            writeln!(f, "+++ b/{}", self.path)?;
107        } else if self.is_deleted_file {
108            writeln!(f, "deleted file mode 100644")?;
109            writeln!(f, "--- a/{}", self.path)?;
110            writeln!(f, "+++ /dev/null")?;
111        } else {
112            writeln!(f, "--- a/{}", self.path)?;
113            writeln!(f, "+++ b/{}", self.path)?;
114        }
115
116        for hunk in &self.hunks {
117            write!(f, "{}", hunk)?;
118        }
119
120        Ok(())
121    }
122}
123
124/// Determines the patch prefix for a given LineSource.
125/// Returns None for lines that should be skipped.
126fn line_source_to_prefix(source: &LineSource) -> Option<char> {
127    match source {
128        // Context lines
129        LineSource::Base => Some(' '),
130
131        // Additions
132        LineSource::Committed | LineSource::Staged | LineSource::Unstaged => Some('+'),
133
134        // Deletions
135        LineSource::DeletedBase | LineSource::DeletedCommitted | LineSource::DeletedStaged => {
136            Some('-')
137        }
138
139        // Skip these
140        LineSource::CanceledCommitted
141        | LineSource::CanceledStaged
142        | LineSource::FileHeader
143        | LineSource::Elided => None,
144    }
145}
146
147/// Converts DiffLines into PatchLines, filtering out non-patch lines.
148fn diff_lines_to_patch_lines(lines: &[DiffLine]) -> Vec<PatchLine> {
149    let mut patch_lines = Vec::new();
150    let mut old_line_num = 0usize;
151    let mut new_line_num = 0usize;
152
153    for diff_line in lines {
154        let Some(prefix) = line_source_to_prefix(&diff_line.source) else {
155            continue;
156        };
157
158        // Track line numbers based on prefix
159        let (old_line, new_line) = match prefix {
160            ' ' => {
161                // Context: appears in both old and new
162                old_line_num += 1;
163                new_line_num += 1;
164                (Some(old_line_num), Some(new_line_num))
165            }
166            '-' => {
167                // Deletion: only in old
168                old_line_num += 1;
169                (Some(old_line_num), None)
170            }
171            '+' => {
172                // Addition: only in new
173                new_line_num += 1;
174                (None, Some(new_line_num))
175            }
176            _ => (None, None),
177        };
178
179        patch_lines.push(PatchLine {
180            prefix,
181            content: diff_line.content.clone(),
182            old_line,
183            new_line,
184        });
185    }
186
187    patch_lines
188}
189
190/// Identifies indices of lines that are changes (additions or deletions).
191fn find_change_indices(lines: &[PatchLine]) -> Vec<usize> {
192    lines
193        .iter()
194        .enumerate()
195        .filter(|(_, line)| line.prefix == '+' || line.prefix == '-')
196        .map(|(i, _)| i)
197        .collect()
198}
199
200/// Builds hunks from patch lines with appropriate context.
201fn build_hunks(lines: &[PatchLine]) -> Vec<Hunk> {
202    if lines.is_empty() {
203        return Vec::new();
204    }
205
206    let change_indices = find_change_indices(lines);
207    if change_indices.is_empty() {
208        return Vec::new();
209    }
210
211    // Determine which lines to include in hunks (changes + context)
212    let mut included = vec![false; lines.len()];
213
214    for &idx in &change_indices {
215        // Include the change itself
216        included[idx] = true;
217
218        // Include context before
219        let start = idx.saturating_sub(CONTEXT_LINES);
220        for item in included.iter_mut().take(idx).skip(start) {
221            *item = true;
222        }
223
224        // Include context after
225        let end = (idx + CONTEXT_LINES + 1).min(lines.len());
226        for item in included.iter_mut().take(end).skip(idx + 1) {
227            *item = true;
228        }
229    }
230
231    // Group consecutive included lines into hunks
232    let mut hunks = Vec::new();
233    let mut hunk_start: Option<usize> = None;
234
235    for (i, &inc) in included.iter().enumerate() {
236        match (inc, hunk_start) {
237            (true, None) => {
238                hunk_start = Some(i);
239            }
240            (false, Some(start)) => {
241                hunks.push(create_hunk(&lines[start..i]));
242                hunk_start = None;
243            }
244            _ => {}
245        }
246    }
247
248    // Handle final hunk
249    if let Some(start) = hunk_start {
250        hunks.push(create_hunk(&lines[start..]));
251    }
252
253    hunks
254}
255
256/// Creates a Hunk from a slice of PatchLines.
257fn create_hunk(lines: &[PatchLine]) -> Hunk {
258    let mut old_count = 0;
259    let mut new_count = 0;
260    let mut old_start = None;
261    let mut new_start = None;
262
263    for line in lines {
264        match line.prefix {
265            ' ' => {
266                old_count += 1;
267                new_count += 1;
268                if old_start.is_none() {
269                    old_start = line.old_line;
270                }
271                if new_start.is_none() {
272                    new_start = line.new_line;
273                }
274            }
275            '-' => {
276                old_count += 1;
277                if old_start.is_none() {
278                    old_start = line.old_line;
279                }
280            }
281            '+' => {
282                new_count += 1;
283                if new_start.is_none() {
284                    new_start = line.new_line;
285                }
286            }
287            _ => {}
288        }
289    }
290
291    // Per git spec:
292    // - New files (no old lines): old_start = 0, old_count = 0
293    // - Deleted files (no new lines): new_start = 0, new_count = 0
294    // - Otherwise: start defaults to 1 if somehow unset
295    let old_start = if old_count == 0 { 0 } else { old_start.unwrap_or(1) };
296    let new_start = if new_count == 0 { 0 } else { new_start.unwrap_or(1) };
297
298    Hunk {
299        old_start,
300        old_count,
301        new_start,
302        new_count,
303        lines: lines.to_vec(),
304    }
305}
306
307/// Generates a git unified patch from DiffLines.
308///
309/// The output is suitable for `git apply` or as a `.diff` file.
310/// Lines without a file_path are skipped since patches require file context.
311pub fn generate_patch(lines: &[DiffLine]) -> String {
312    // Group lines by file path, skipping lines without a path
313    let mut files: Vec<(String, Vec<&DiffLine>)> = Vec::new();
314    let mut current_path: Option<String> = None;
315
316    for line in lines {
317        let path = match &line.file_path {
318            Some(p) if !p.is_empty() => p.clone(),
319            _ => continue, // Skip lines without a valid file path
320        };
321
322        if current_path.as_ref() != Some(&path) {
323            files.push((path.clone(), Vec::new()));
324            current_path = Some(path);
325        }
326
327        if let Some((_, file_lines)) = files.last_mut() {
328            file_lines.push(line);
329        }
330    }
331
332    // Generate patch for each file
333    let mut output = String::new();
334
335    for (path, file_lines) in files {
336        let owned_lines: Vec<DiffLine> = file_lines.into_iter().cloned().collect();
337        let patch_lines = diff_lines_to_patch_lines(&owned_lines);
338        let hunks = build_hunks(&patch_lines);
339
340        let file_patch = FilePatch::new(path, hunks);
341        output.push_str(&file_patch.to_string());
342    }
343
344    output
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    fn make_diff_line(source: LineSource, content: &str, file_path: &str) -> DiffLine {
352        DiffLine {
353            source,
354            content: content.to_string(),
355            prefix: match source {
356                LineSource::Base => ' ',
357                LineSource::Committed | LineSource::Staged | LineSource::Unstaged => '+',
358                LineSource::DeletedBase
359                | LineSource::DeletedCommitted
360                | LineSource::DeletedStaged => '-',
361                _ => ' ',
362            },
363            line_number: None,
364            file_path: Some(file_path.to_string()),
365            inline_spans: Vec::new(),
366            old_content: None,
367            change_source: None,
368            in_current_bookmark: None,
369        }
370    }
371
372    #[test]
373    fn test_simple_addition() {
374        let lines = vec![
375            make_diff_line(LineSource::Base, "line 1", "test.txt"),
376            make_diff_line(LineSource::Base, "line 2", "test.txt"),
377            make_diff_line(LineSource::Base, "line 3", "test.txt"),
378            make_diff_line(LineSource::Committed, "new line", "test.txt"),
379            make_diff_line(LineSource::Base, "line 4", "test.txt"),
380            make_diff_line(LineSource::Base, "line 5", "test.txt"),
381            make_diff_line(LineSource::Base, "line 6", "test.txt"),
382        ];
383
384        let patch = generate_patch(&lines);
385
386        assert!(patch.contains("diff --git a/test.txt b/test.txt"));
387        assert!(patch.contains("--- a/test.txt"));
388        assert!(patch.contains("+++ b/test.txt"));
389        assert!(patch.contains("+new line"));
390        assert!(patch.contains("@@ -"));
391    }
392
393    #[test]
394    fn test_simple_deletion() {
395        let lines = vec![
396            make_diff_line(LineSource::Base, "line 1", "test.txt"),
397            make_diff_line(LineSource::Base, "line 2", "test.txt"),
398            make_diff_line(LineSource::Base, "line 3", "test.txt"),
399            make_diff_line(LineSource::DeletedCommitted, "deleted line", "test.txt"),
400            make_diff_line(LineSource::Base, "line 4", "test.txt"),
401            make_diff_line(LineSource::Base, "line 5", "test.txt"),
402            make_diff_line(LineSource::Base, "line 6", "test.txt"),
403        ];
404
405        let patch = generate_patch(&lines);
406
407        assert!(patch.contains("-deleted line"));
408    }
409
410    #[test]
411    fn test_mixed_changes() {
412        let lines = vec![
413            make_diff_line(LineSource::Base, "context", "test.txt"),
414            make_diff_line(LineSource::DeletedStaged, "old line", "test.txt"),
415            make_diff_line(LineSource::Staged, "new line", "test.txt"),
416            make_diff_line(LineSource::Base, "more context", "test.txt"),
417        ];
418
419        let patch = generate_patch(&lines);
420
421        assert!(patch.contains("-old line"));
422        assert!(patch.contains("+new line"));
423    }
424
425    #[test]
426    fn test_multiple_files() {
427        let lines = vec![
428            make_diff_line(LineSource::Base, "file1 line", "file1.txt"),
429            make_diff_line(LineSource::Committed, "file1 addition", "file1.txt"),
430            make_diff_line(LineSource::Base, "file2 line", "file2.txt"),
431            make_diff_line(LineSource::Unstaged, "file2 addition", "file2.txt"),
432        ];
433
434        let patch = generate_patch(&lines);
435
436        assert!(patch.contains("diff --git a/file1.txt b/file1.txt"));
437        assert!(patch.contains("diff --git a/file2.txt b/file2.txt"));
438        assert!(patch.contains("+file1 addition"));
439        assert!(patch.contains("+file2 addition"));
440    }
441
442    #[test]
443    fn test_skips_canceled_lines() {
444        let lines = vec![
445            make_diff_line(LineSource::Base, "context", "test.txt"),
446            make_diff_line(LineSource::CanceledCommitted, "canceled", "test.txt"),
447            make_diff_line(LineSource::Committed, "actual change", "test.txt"),
448        ];
449
450        let patch = generate_patch(&lines);
451
452        assert!(!patch.contains("canceled"));
453        assert!(patch.contains("+actual change"));
454    }
455
456    #[test]
457    fn test_skips_file_header() {
458        let lines = vec![
459            make_diff_line(LineSource::FileHeader, "src/test.txt", "test.txt"),
460            make_diff_line(LineSource::Base, "context", "test.txt"),
461            make_diff_line(LineSource::Committed, "change", "test.txt"),
462        ];
463
464        let patch = generate_patch(&lines);
465
466        // FileHeader content should not appear as a change line
467        let lines: Vec<&str> = patch.lines().collect();
468        assert!(!lines.iter().any(|l| *l == "+src/test.txt" || *l == "-src/test.txt"));
469    }
470
471    #[test]
472    fn test_empty_diff() {
473        let lines: Vec<DiffLine> = vec![];
474        let patch = generate_patch(&lines);
475        assert!(patch.is_empty());
476    }
477
478    #[test]
479    fn test_no_changes() {
480        let lines = vec![
481            make_diff_line(LineSource::Base, "line 1", "test.txt"),
482            make_diff_line(LineSource::Base, "line 2", "test.txt"),
483        ];
484
485        let patch = generate_patch(&lines);
486
487        // No hunks should be generated for files with no changes
488        assert!(!patch.contains("@@"));
489    }
490
491    #[test]
492    fn test_hunk_header_format_with_counts() {
493        let hunk = Hunk {
494            old_start: 10,
495            old_count: 5,
496            new_start: 12,
497            new_count: 7,
498            lines: vec![],
499        };
500
501        assert_eq!(hunk.header(), "@@ -10,5 +12,7 @@");
502    }
503
504    #[test]
505    fn test_hunk_header_omits_count_when_one() {
506        // Per git spec, count is omitted when it equals 1
507        let hunk = Hunk {
508            old_start: 5,
509            old_count: 1,
510            new_start: 7,
511            new_count: 1,
512            lines: vec![],
513        };
514
515        assert_eq!(hunk.header(), "@@ -5 +7 @@");
516    }
517
518    #[test]
519    fn test_hunk_header_mixed_counts() {
520        let hunk = Hunk {
521            old_start: 10,
522            old_count: 1,
523            new_start: 12,
524            new_count: 3,
525            lines: vec![],
526        };
527
528        assert_eq!(hunk.header(), "@@ -10 +12,3 @@");
529    }
530
531    #[test]
532    fn test_hunk_header_zero_counts() {
533        // Zero counts for new/deleted files
534        let hunk = Hunk {
535            old_start: 0,
536            old_count: 0,
537            new_start: 1,
538            new_count: 5,
539            lines: vec![],
540        };
541
542        assert_eq!(hunk.header(), "@@ -0,0 +1,5 @@");
543    }
544
545    #[test]
546    fn test_context_limiting() {
547        // Create a file with many lines and one change in the middle
548        let mut lines = Vec::new();
549        for i in 1..=20 {
550            lines.push(make_diff_line(
551                LineSource::Base,
552                &format!("line {}", i),
553                "test.txt",
554            ));
555        }
556        // Insert a change at position 10
557        lines.insert(
558            10,
559            make_diff_line(LineSource::Committed, "new line", "test.txt"),
560        );
561
562        let patch = generate_patch(&lines);
563
564        // Should have context but not all 20+ lines
565        let line_count = patch.lines().count();
566        // Header (3) + hunk header (1) + 3 context before + 1 change + 3 context after = 11
567        assert!(line_count < 15, "Patch should be limited: {}", line_count);
568    }
569
570    #[test]
571    fn test_all_change_types_combined() {
572        let lines = vec![
573            make_diff_line(LineSource::Base, "context 1", "test.txt"),
574            make_diff_line(LineSource::DeletedBase, "deleted base", "test.txt"),
575            make_diff_line(LineSource::Committed, "committed add", "test.txt"),
576            make_diff_line(LineSource::Base, "context 2", "test.txt"),
577            make_diff_line(LineSource::DeletedCommitted, "deleted committed", "test.txt"),
578            make_diff_line(LineSource::Staged, "staged add", "test.txt"),
579            make_diff_line(LineSource::Base, "context 3", "test.txt"),
580            make_diff_line(LineSource::DeletedStaged, "deleted staged", "test.txt"),
581            make_diff_line(LineSource::Unstaged, "unstaged add", "test.txt"),
582        ];
583
584        let patch = generate_patch(&lines);
585
586        assert!(patch.contains("-deleted base"));
587        assert!(patch.contains("+committed add"));
588        assert!(patch.contains("-deleted committed"));
589        assert!(patch.contains("+staged add"));
590        assert!(patch.contains("-deleted staged"));
591        assert!(patch.contains("+unstaged add"));
592    }
593
594    #[test]
595    fn test_new_file_uses_dev_null() {
596        // A new file has only additions (no context, no deletions)
597        let lines = vec![
598            make_diff_line(LineSource::Committed, "line 1", "new_file.txt"),
599            make_diff_line(LineSource::Committed, "line 2", "new_file.txt"),
600            make_diff_line(LineSource::Committed, "line 3", "new_file.txt"),
601        ];
602
603        let patch = generate_patch(&lines);
604
605        assert!(patch.contains("new file mode 100644"));
606        assert!(patch.contains("--- /dev/null"));
607        assert!(patch.contains("+++ b/new_file.txt"));
608        assert!(patch.contains("@@ -0,0 +1,3 @@"));
609    }
610
611    #[test]
612    fn test_deleted_file_uses_dev_null() {
613        // A deleted file has only deletions (no context, no additions)
614        let lines = vec![
615            make_diff_line(LineSource::DeletedCommitted, "line 1", "deleted.txt"),
616            make_diff_line(LineSource::DeletedCommitted, "line 2", "deleted.txt"),
617        ];
618
619        let patch = generate_patch(&lines);
620
621        assert!(patch.contains("deleted file mode 100644"));
622        assert!(patch.contains("--- a/deleted.txt"));
623        assert!(patch.contains("+++ /dev/null"));
624        assert!(patch.contains("@@ -1,2 +0,0 @@"));
625    }
626
627    #[test]
628    fn test_line_numbers_are_correct() {
629        // Verify exact line numbers in the generated hunk header
630        let lines = vec![
631            make_diff_line(LineSource::Base, "line 1", "test.txt"),
632            make_diff_line(LineSource::Base, "line 2", "test.txt"),
633            make_diff_line(LineSource::Base, "line 3", "test.txt"),
634            make_diff_line(LineSource::Committed, "inserted", "test.txt"),
635            make_diff_line(LineSource::Base, "line 4", "test.txt"),
636        ];
637
638        let patch = generate_patch(&lines);
639
640        // Hunk contains:
641        // - Old file: 4 lines (line 1, 2, 3, 4 - context around insertion)
642        // - New file: 5 lines (line 1, 2, 3, inserted, 4)
643        assert!(
644            patch.contains("@@ -1,4 +1,5 @@"),
645            "Unexpected hunk header in:\n{}",
646            patch
647        );
648    }
649
650    #[test]
651    fn test_deletion_line_numbers() {
652        let lines = vec![
653            make_diff_line(LineSource::Base, "keep 1", "test.txt"),
654            make_diff_line(LineSource::Base, "keep 2", "test.txt"),
655            make_diff_line(LineSource::DeletedCommitted, "removed", "test.txt"),
656            make_diff_line(LineSource::Base, "keep 3", "test.txt"),
657            make_diff_line(LineSource::Base, "keep 4", "test.txt"),
658        ];
659
660        let patch = generate_patch(&lines);
661
662        // Old file: 5 lines (4 kept + 1 deleted)
663        // New file: 4 lines (the kept ones)
664        assert!(
665            patch.contains("@@ -1,5 +1,4 @@"),
666            "Unexpected hunk header in:\n{}",
667            patch
668        );
669    }
670
671    #[test]
672    fn test_path_with_spaces() {
673        // Git diff format doesn't escape spaces in paths
674        let lines = vec![
675            make_diff_line(LineSource::Base, "content", "path with spaces/file name.txt"),
676            make_diff_line(LineSource::Committed, "new", "path with spaces/file name.txt"),
677        ];
678
679        let patch = generate_patch(&lines);
680
681        assert!(patch.contains("diff --git a/path with spaces/file name.txt b/path with spaces/file name.txt"));
682        assert!(patch.contains("--- a/path with spaces/file name.txt"));
683        assert!(patch.contains("+++ b/path with spaces/file name.txt"));
684    }
685
686    #[test]
687    fn test_lines_without_file_path_are_skipped() {
688        fn make_line_no_path(source: LineSource, content: &str) -> DiffLine {
689            DiffLine {
690                source,
691                content: content.to_string(),
692                prefix: ' ',
693                line_number: None,
694                file_path: None,
695                inline_spans: Vec::new(),
696                old_content: None,
697                change_source: None,
698                in_current_bookmark: None,
699            }
700        }
701
702        let lines = vec![
703            make_line_no_path(LineSource::Base, "orphan line"),
704            make_diff_line(LineSource::Base, "context", "test.txt"),
705            make_diff_line(LineSource::Committed, "change", "test.txt"),
706        ];
707
708        let patch = generate_patch(&lines);
709
710        // Orphan line should not appear in output
711        assert!(!patch.contains("orphan"));
712        // But the file with path should be processed
713        assert!(patch.contains("+change"));
714    }
715
716    #[test]
717    fn test_lines_with_empty_file_path_are_skipped() {
718        fn make_line_empty_path(source: LineSource, content: &str) -> DiffLine {
719            DiffLine {
720                source,
721                content: content.to_string(),
722                prefix: ' ',
723                line_number: None,
724                file_path: Some(String::new()),
725                inline_spans: Vec::new(),
726                old_content: None,
727                change_source: None,
728                in_current_bookmark: None,
729            }
730        }
731
732        let lines = vec![
733            make_line_empty_path(LineSource::Committed, "orphan"),
734            make_diff_line(LineSource::Committed, "real change", "test.txt"),
735        ];
736
737        let patch = generate_patch(&lines);
738
739        assert!(!patch.contains("orphan"));
740        assert!(patch.contains("+real change"));
741    }
742
743    #[test]
744    fn test_format_range_helper() {
745        assert_eq!(format_range(1, 1), "1");
746        assert_eq!(format_range(5, 1), "5");
747        assert_eq!(format_range(1, 3), "1,3");
748        assert_eq!(format_range(10, 0), "10,0");
749        assert_eq!(format_range(0, 0), "0,0");
750    }
751}