1use crate::unit::Status;
4use anyhow::{Context, Result};
5use std::path::Path;
6use std::str::FromStr;
7
8pub fn validate_unit_id(id: &str) -> Result<()> {
21 if id.is_empty() {
22 return Err(anyhow::anyhow!("Unit ID cannot be empty"));
23 }
24
25 if id.len() > 255 {
26 return Err(anyhow::anyhow!("Unit ID too long (max 255 characters)"));
27 }
28
29 if !id
31 .chars()
32 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
33 {
34 return Err(anyhow::anyhow!(
35 "Invalid unit ID '{}': must contain only alphanumeric characters, dots, underscores, and hyphens",
36 id
37 ));
38 }
39
40 if id.contains("..") {
42 return Err(anyhow::anyhow!(
43 "Invalid unit ID '{}': cannot contain '..' (path traversal protection)",
44 id
45 ));
46 }
47
48 Ok(())
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
54enum IdSegment {
55 Num(u64),
56 Alpha(String),
57}
58
59impl PartialOrd for IdSegment {
60 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
61 Some(self.cmp(other))
62 }
63}
64
65impl Ord for IdSegment {
66 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
67 match (self, other) {
68 (IdSegment::Num(a), IdSegment::Num(b)) => a.cmp(b),
69 (IdSegment::Alpha(a), IdSegment::Alpha(b)) => a.cmp(b),
70 (IdSegment::Num(_), IdSegment::Alpha(_)) => std::cmp::Ordering::Less,
72 (IdSegment::Alpha(_), IdSegment::Num(_)) => std::cmp::Ordering::Greater,
73 }
74 }
75}
76
77pub fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
89 let sa = parse_id_segments(a);
90 let sb = parse_id_segments(b);
91 sa.cmp(&sb)
92}
93
94fn parse_id_segments(id: &str) -> Vec<IdSegment> {
105 id.split('.')
106 .map(|seg| match seg.parse::<u64>() {
107 Ok(n) => IdSegment::Num(n),
108 Err(_) => IdSegment::Alpha(seg.to_string()),
109 })
110 .collect()
111}
112
113pub fn parse_status(s: &str) -> Option<Status> {
117 match s {
118 "open" => Some(Status::Open),
119 "in_progress" => Some(Status::InProgress),
120 "closed" => Some(Status::Closed),
121 _ => None,
122 }
123}
124
125impl FromStr for Status {
127 type Err = String;
128
129 fn from_str(s: &str) -> Result<Self, Self::Err> {
130 parse_status(s).ok_or_else(|| format!("Invalid status: {}", s))
131 }
132}
133
134pub fn title_to_slug(title: &str) -> String {
157 let trimmed = title.trim();
159
160 let lowercased = trimmed.to_lowercase();
162
163 let mut slug = String::new();
165 for c in lowercased.chars() {
166 if c.is_ascii_alphanumeric() {
167 slug.push(c);
168 } else if c.is_whitespace() || c == '-' {
169 slug.push('-');
170 }
171 }
173
174 let slug = slug.chars().fold(String::new(), |mut acc, c| {
176 if c == '-' && acc.ends_with('-') {
177 acc
178 } else {
179 acc.push(c);
180 acc
181 }
182 });
183
184 let slug = slug.trim_matches('-').to_string();
186
187 let slug = if slug.len() > 50 {
189 slug.chars()
190 .take(50)
191 .collect::<String>()
192 .trim_end_matches('-')
193 .to_string()
194 } else {
195 slug
196 };
197
198 if slug.is_empty() {
200 "unnamed".to_string()
201 } else {
202 slug
203 }
204}
205
206fn normalize_title_words(title: &str) -> Vec<String> {
211 let stop_words: &[&str] = &[
212 "a", "an", "the", "to", "in", "on", "of", "for", "and", "or", "is", "it", "by", "at", "be",
213 "do", "up", "as", "so", "if", "no", "not", "but", "all", "can", "had", "has", "was", "are",
214 "its", "may", "our", "out", "own", "too", "use", "via", "way", "yet", "with", "from",
215 "that", "this", "into", "when", "will", "been", "have", "each", "make", "than", "them",
216 "then", "some",
217 ];
218
219 let lowered = title.to_lowercase();
220 lowered
221 .split(|c: char| !c.is_ascii_alphanumeric())
222 .map(|w| w.trim())
223 .filter(|w| !w.is_empty() && w.len() > 1 && !stop_words.contains(w))
224 .map(|w| w.to_string())
225 .collect()
226}
227
228pub fn title_similarity(a: &str, b: &str) -> f64 {
235 let words_a = normalize_title_words(a);
236 let words_b = normalize_title_words(b);
237
238 if words_a.is_empty() || words_b.is_empty() {
239 return 0.0;
240 }
241
242 let intersection = words_a.iter().filter(|w| words_b.contains(w)).count();
243 let min_len = words_a.len().min(words_b.len());
244
245 intersection as f64 / min_len as f64
246}
247
248#[derive(Debug, Clone)]
250pub struct SimilarUnit {
251 pub id: String,
252 pub title: String,
253 pub score: f64,
254}
255
256pub fn find_similar_titles(
261 index: &crate::index::Index,
262 new_title: &str,
263 threshold: f64,
264) -> Vec<SimilarUnit> {
265 let mut matches = Vec::new();
266
267 for entry in &index.units {
268 if entry.status != Status::Open && entry.status != Status::InProgress {
269 continue;
270 }
271
272 let score = title_similarity(new_title, &entry.title);
273 if score >= threshold {
274 matches.push(SimilarUnit {
275 id: entry.id.clone(),
276 title: entry.title.clone(),
277 score,
278 });
279 }
280 }
281
282 matches.sort_by(|a, b| {
284 b.score
285 .partial_cmp(&a.score)
286 .unwrap_or(std::cmp::Ordering::Equal)
287 });
288 matches
289}
290
291pub const DEFAULT_SIMILARITY_THRESHOLD: f64 = 0.7;
293
294pub fn atomic_write(path: &Path, contents: &str) -> Result<()> {
301 let tmp_path = path.with_extension(format!("tmp.{}", std::process::id()));
302
303 if let Err(e) = std::fs::write(&tmp_path, contents) {
305 let _ = std::fs::remove_file(&tmp_path);
306 return Err(e)
307 .with_context(|| format!("Failed to write temp file: {}", tmp_path.display()));
308 }
309
310 if let Err(e) = std::fs::rename(&tmp_path, path) {
312 let _ = std::fs::remove_file(&tmp_path);
313 return Err(e).with_context(|| {
314 format!(
315 "Failed to rename {} -> {}",
316 tmp_path.display(),
317 path.display()
318 )
319 });
320 }
321
322 Ok(())
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
332 fn title_to_slug_simple_case() {
333 assert_eq!(title_to_slug("My Task"), "my-task");
334 }
335
336 #[test]
337 fn title_to_slug_with_numbers_and_dots() {
338 assert_eq!(title_to_slug("Build API v2.0"), "build-api-v20");
339 }
340
341 #[test]
342 fn title_to_slug_multiple_spaces() {
343 assert_eq!(title_to_slug("Foo Bar"), "foo-bar");
344 }
345
346 #[test]
347 fn title_to_slug_with_backticks() {
348 assert_eq!(
349 title_to_slug("Implement `mana show` to render Markdown"),
350 "implement-mana-show-to-render-markdown"
351 );
352 }
353
354 #[test]
355 fn title_to_slug_with_special_chars() {
356 assert_eq!(
357 title_to_slug("Update Unit parser to read .md + YAML frontmatter"),
358 "update-unit-parser-to-read-md-yaml-frontmatter"
359 );
360 }
361
362 #[test]
363 fn title_to_slug_with_exclamation() {
364 assert_eq!(title_to_slug("My-Task!!!"), "my-task");
365 }
366
367 #[test]
368 fn title_to_slug_leading_trailing_spaces() {
369 assert_eq!(title_to_slug(" Spaces "), "spaces");
370 }
371
372 #[test]
373 fn title_to_slug_empty_string() {
374 assert_eq!(title_to_slug(""), "unnamed");
375 }
376
377 #[test]
378 fn title_to_slug_single_character() {
379 assert_eq!(title_to_slug("a"), "a");
380 assert_eq!(title_to_slug("Z"), "z");
381 }
382
383 #[test]
384 fn title_to_slug_only_spaces() {
385 assert_eq!(title_to_slug(" "), "unnamed");
386 }
387
388 #[test]
389 fn title_to_slug_only_special_chars() {
390 assert_eq!(title_to_slug("!!!@@@###"), "unnamed");
391 }
392
393 #[test]
394 fn title_to_slug_truncate_50_chars() {
395 let long_title = "a".repeat(60);
396 let result = title_to_slug(&long_title);
397 assert_eq!(result, "a".repeat(50));
398 assert_eq!(result.len(), 50);
399 }
400
401 #[test]
402 fn title_to_slug_truncate_with_hyphens() {
403 let title = "word ".repeat(20); let result = title_to_slug(&title);
405 assert!(result.len() <= 50);
406 }
407
408 #[test]
409 fn title_to_slug_mixed_case() {
410 assert_eq!(
411 title_to_slug("ThIs Is A MiXeD CaSe TiTle"),
412 "this-is-a-mixed-case-title"
413 );
414 }
415
416 #[test]
417 fn title_to_slug_numbers_preserved() {
418 assert_eq!(
419 title_to_slug("Task 123 Version 4.5.6"),
420 "task-123-version-456"
421 );
422 }
423
424 #[test]
425 fn title_to_slug_consecutive_hyphens() {
426 assert_eq!(title_to_slug("foo---bar"), "foo-bar");
427 assert_eq!(title_to_slug("foo - - bar"), "foo-bar");
428 }
429
430 #[test]
431 fn title_to_slug_unicode_removed() {
432 assert_eq!(title_to_slug("café"), "caf");
434 assert_eq!(title_to_slug("naïve"), "nave");
435 }
436
437 #[test]
438 fn title_to_slug_all_whitespace_types() {
439 assert_eq!(title_to_slug("foo\tbar\nbaz"), "foo-bar-baz");
440 }
441
442 #[test]
443 fn title_to_slug_exactly_50_chars() {
444 let title = "a".repeat(50);
445 assert_eq!(title_to_slug(&title), title);
446 }
447
448 #[test]
451 fn natural_cmp_single_digit() {
452 assert_eq!(natural_cmp("1", "2"), std::cmp::Ordering::Less);
453 assert_eq!(natural_cmp("2", "1"), std::cmp::Ordering::Greater);
454 assert_eq!(natural_cmp("1", "1"), std::cmp::Ordering::Equal);
455 }
456
457 #[test]
458 fn natural_cmp_multi_digit() {
459 assert_eq!(natural_cmp("1", "10"), std::cmp::Ordering::Less);
460 assert_eq!(natural_cmp("10", "1"), std::cmp::Ordering::Greater);
461 assert_eq!(natural_cmp("10", "10"), std::cmp::Ordering::Equal);
462 }
463
464 #[test]
465 fn natural_cmp_multi_level() {
466 assert_eq!(natural_cmp("3.1", "3.2"), std::cmp::Ordering::Less);
467 assert_eq!(natural_cmp("3.2", "3.1"), std::cmp::Ordering::Greater);
468 assert_eq!(natural_cmp("3.1", "3.1"), std::cmp::Ordering::Equal);
469 }
470
471 #[test]
472 fn natural_cmp_three_level() {
473 assert_eq!(natural_cmp("3.2.1", "3.2.2"), std::cmp::Ordering::Less);
474 assert_eq!(natural_cmp("3.2.2", "3.2.1"), std::cmp::Ordering::Greater);
475 assert_eq!(natural_cmp("3.2.1", "3.2.1"), std::cmp::Ordering::Equal);
476 }
477
478 #[test]
479 fn natural_cmp_different_prefix() {
480 assert_eq!(natural_cmp("2.1", "3.1"), std::cmp::Ordering::Less);
481 assert_eq!(natural_cmp("10.5", "9.99"), std::cmp::Ordering::Greater);
482 }
483
484 #[test]
487 fn parse_id_segments_single() {
488 assert_eq!(parse_id_segments("1"), vec![IdSegment::Num(1)]);
489 assert_eq!(parse_id_segments("42"), vec![IdSegment::Num(42)]);
490 }
491
492 #[test]
493 fn parse_id_segments_multi_level() {
494 assert_eq!(
495 parse_id_segments("1.2"),
496 vec![IdSegment::Num(1), IdSegment::Num(2)]
497 );
498 assert_eq!(
499 parse_id_segments("3.2.1"),
500 vec![IdSegment::Num(3), IdSegment::Num(2), IdSegment::Num(1)]
501 );
502 }
503
504 #[test]
505 fn parse_id_segments_leading_zeros() {
506 assert_eq!(parse_id_segments("01"), vec![IdSegment::Num(1)]);
508 assert_eq!(
509 parse_id_segments("03.02"),
510 vec![IdSegment::Num(3), IdSegment::Num(2)]
511 );
512 }
513
514 #[test]
515 fn parse_id_segments_alpha() {
516 assert_eq!(
517 parse_id_segments("abc"),
518 vec![IdSegment::Alpha("abc".to_string())]
519 );
520 assert_eq!(
521 parse_id_segments("1.abc.2"),
522 vec![
523 IdSegment::Num(1),
524 IdSegment::Alpha("abc".to_string()),
525 IdSegment::Num(2)
526 ]
527 );
528 }
529
530 #[test]
531 fn natural_cmp_alpha_ids() {
532 assert_eq!(natural_cmp("abc", "def"), std::cmp::Ordering::Less);
534 assert_eq!(natural_cmp("def", "abc"), std::cmp::Ordering::Greater);
535 assert_eq!(natural_cmp("abc", "abc"), std::cmp::Ordering::Equal);
536 }
537
538 #[test]
539 fn natural_cmp_numeric_before_alpha() {
540 assert_eq!(natural_cmp("1", "abc"), std::cmp::Ordering::Less);
541 assert_eq!(natural_cmp("abc", "1"), std::cmp::Ordering::Greater);
542 }
543
544 #[test]
545 fn natural_cmp_mixed_segments() {
546 assert_eq!(natural_cmp("1.abc.2", "1.abc.3"), std::cmp::Ordering::Less);
548 assert_eq!(natural_cmp("1.abc", "1.def"), std::cmp::Ordering::Less);
550 }
551
552 #[test]
555 fn parse_status_valid_open() {
556 assert_eq!(parse_status("open"), Some(Status::Open));
557 }
558
559 #[test]
560 fn parse_status_valid_in_progress() {
561 assert_eq!(parse_status("in_progress"), Some(Status::InProgress));
562 }
563
564 #[test]
565 fn parse_status_valid_closed() {
566 assert_eq!(parse_status("closed"), Some(Status::Closed));
567 }
568
569 #[test]
570 fn parse_status_invalid() {
571 assert_eq!(parse_status("invalid"), None);
572 assert_eq!(parse_status(""), None);
573 assert_eq!(parse_status("OPEN"), None);
574 assert_eq!(parse_status("Closed"), None);
575 }
576
577 #[test]
578 fn parse_status_whitespace() {
579 assert_eq!(parse_status("open "), None);
580 assert_eq!(parse_status(" open"), None);
581 }
582
583 #[test]
586 fn status_from_str_open() {
587 assert_eq!("open".parse::<Status>(), Ok(Status::Open));
588 }
589
590 #[test]
591 fn status_from_str_in_progress() {
592 assert_eq!("in_progress".parse::<Status>(), Ok(Status::InProgress));
593 }
594
595 #[test]
596 fn status_from_str_closed() {
597 assert_eq!("closed".parse::<Status>(), Ok(Status::Closed));
598 }
599
600 #[test]
601 fn status_from_str_invalid() {
602 assert!("invalid".parse::<Status>().is_err());
603 assert!("".parse::<Status>().is_err());
604 }
605
606 #[test]
609 fn validate_unit_id_simple_numeric() {
610 assert!(validate_unit_id("1").is_ok());
611 assert!(validate_unit_id("42").is_ok());
612 assert!(validate_unit_id("999").is_ok());
613 }
614
615 #[test]
616 fn validate_unit_id_dotted() {
617 assert!(validate_unit_id("3.1").is_ok());
618 assert!(validate_unit_id("3.2.1").is_ok());
619 assert!(validate_unit_id("1.2.3.4.5").is_ok());
620 }
621
622 #[test]
623 fn validate_unit_id_with_underscores() {
624 assert!(validate_unit_id("task_1").is_ok());
625 assert!(validate_unit_id("my_task_v1").is_ok());
626 }
627
628 #[test]
629 fn validate_unit_id_with_hyphens() {
630 assert!(validate_unit_id("my-task").is_ok());
631 assert!(validate_unit_id("task-v1-0").is_ok());
632 }
633
634 #[test]
635 fn validate_unit_id_alphanumeric() {
636 assert!(validate_unit_id("abc123def").is_ok());
637 assert!(validate_unit_id("Task1").is_ok());
638 }
639
640 #[test]
641 fn validate_unit_id_empty_fails() {
642 assert!(validate_unit_id("").is_err());
643 }
644
645 #[test]
646 fn validate_unit_id_path_traversal_fails() {
647 assert!(validate_unit_id("../etc/passwd").is_err());
648 assert!(validate_unit_id("..").is_err());
649 assert!(validate_unit_id("foo/../bar").is_err());
650 assert!(validate_unit_id("task..escape").is_err());
651 }
652
653 #[test]
654 fn validate_unit_id_absolute_path_fails() {
655 assert!(validate_unit_id("/etc/passwd").is_err());
656 }
657
658 #[test]
659 fn validate_unit_id_spaces_fail() {
660 assert!(validate_unit_id("my task").is_err());
661 assert!(validate_unit_id(" 1").is_err());
662 assert!(validate_unit_id("1 ").is_err());
663 }
664
665 #[test]
666 fn validate_unit_id_special_chars_fail() {
667 assert!(validate_unit_id("task@home").is_err());
668 assert!(validate_unit_id("task#1").is_err());
669 assert!(validate_unit_id("task$money").is_err());
670 assert!(validate_unit_id("task%complete").is_err());
671 assert!(validate_unit_id("task&friend").is_err());
672 assert!(validate_unit_id("task*star").is_err());
673 assert!(validate_unit_id("task(paren").is_err());
674 assert!(validate_unit_id("task)close").is_err());
675 assert!(validate_unit_id("task+plus").is_err());
676 assert!(validate_unit_id("task=equals").is_err());
677 assert!(validate_unit_id("task[bracket").is_err());
678 assert!(validate_unit_id("task]close").is_err());
679 assert!(validate_unit_id("task{brace").is_err());
680 assert!(validate_unit_id("task}close").is_err());
681 assert!(validate_unit_id("task|pipe").is_err());
682 assert!(validate_unit_id("task;semicolon").is_err());
683 assert!(validate_unit_id("task:colon").is_err());
684 assert!(validate_unit_id("task\"quote").is_err());
685 assert!(validate_unit_id("task'apostrophe").is_err());
686 assert!(validate_unit_id("task<less").is_err());
687 assert!(validate_unit_id("task>greater").is_err());
688 assert!(validate_unit_id("task,comma").is_err());
689 assert!(validate_unit_id("task?question").is_err());
690 }
691
692 #[test]
693 fn validate_unit_id_too_long() {
694 let long_id = "a".repeat(256);
695 assert!(validate_unit_id(&long_id).is_err());
696
697 let max_id = "a".repeat(255);
698 assert!(validate_unit_id(&max_id).is_ok());
699 }
700
701 #[test]
704 fn test_atomic_write_creates_file_with_correct_contents() {
705 let dir = tempfile::tempdir().unwrap();
706 let path = dir.path().join("test.yaml");
707
708 atomic_write(&path, "hello: world\n").unwrap();
709
710 let contents = std::fs::read_to_string(&path).unwrap();
711 assert_eq!(contents, "hello: world\n");
712 }
713
714 #[test]
715 fn test_atomic_write_overwrites_existing_file() {
716 let dir = tempfile::tempdir().unwrap();
717 let path = dir.path().join("test.yaml");
718
719 std::fs::write(&path, "old content").unwrap();
720 atomic_write(&path, "new content").unwrap();
721
722 let contents = std::fs::read_to_string(&path).unwrap();
723 assert_eq!(contents, "new content");
724 }
725
726 #[test]
727 fn test_atomic_write_no_temp_file_left_behind() {
728 let dir = tempfile::tempdir().unwrap();
729 let path = dir.path().join("test.yaml");
730
731 atomic_write(&path, "data").unwrap();
732
733 let entries: Vec<_> = std::fs::read_dir(dir.path())
734 .unwrap()
735 .filter_map(|e| e.ok())
736 .collect();
737 assert_eq!(entries.len(), 1, "only the target file should exist");
738 assert_eq!(entries[0].file_name().to_str().unwrap(), "test.yaml");
739 }
740
741 #[test]
744 fn similarity_identical_titles() {
745 assert!(
746 (title_similarity("Fix auth timeout", "Fix auth timeout") - 1.0).abs() < f64::EPSILON
747 );
748 }
749
750 #[test]
751 fn similarity_close_titles() {
752 let score = title_similarity("Fix auth timeout", "Fix authentication timeout handling");
757 assert!(score > 0.5, "Expected > 0.5, got {}", score);
758 }
759
760 #[test]
761 fn similarity_very_different_titles() {
762 let score = title_similarity("Fix auth timeout", "Add database migration");
763 assert!(score < 0.3, "Expected < 0.3, got {}", score);
764 }
765
766 #[test]
767 fn similarity_empty_title() {
768 assert!((title_similarity("", "Something")).abs() < f64::EPSILON);
769 assert!((title_similarity("Something", "")).abs() < f64::EPSILON);
770 }
771
772 #[test]
773 fn similarity_case_insensitive() {
774 let score = title_similarity("Fix Auth Timeout", "fix auth timeout");
775 assert!((score - 1.0).abs() < f64::EPSILON);
776 }
777
778 #[test]
779 fn similarity_ignores_stop_words() {
780 let score = title_similarity("Add a new feature", "Add the new feature");
783 assert!((score - 1.0).abs() < f64::EPSILON);
784 }
785
786 #[test]
787 fn similarity_strips_punctuation() {
788 let score = title_similarity("Fix: auth timeout!", "Fix auth timeout");
789 assert!((score - 1.0).abs() < f64::EPSILON);
790 }
791
792 #[test]
793 fn similarity_subset_match_scores_high() {
794 let score = title_similarity("Fix auth", "Fix auth timeout");
796 assert!((score - 1.0).abs() < f64::EPSILON);
797 }
798
799 #[test]
802 fn find_similar_returns_matches_above_threshold() {
803 use crate::index::{Index, IndexEntry};
804 use chrono::Utc;
805
806 let index = Index {
807 units: vec![
808 IndexEntry {
809 id: "1".to_string(),
810 title: "Fix auth timeout".to_string(),
811 handle: None,
812 status: Status::Open,
813 priority: 2,
814 parent: None,
815 dependencies: vec![],
816 labels: vec![],
817 assignee: None,
818 updated_at: Utc::now(),
819 produces: vec![],
820 requires: vec![],
821 has_verify: false,
822 verify: None,
823 created_at: Utc::now(),
824 claimed_by: None,
825 attempts: 0,
826 paths: vec![],
827 kind: crate::unit::UnitType::Task,
828 feature: false,
829 has_decisions: false,
830 },
831 IndexEntry {
832 id: "2".to_string(),
833 title: "Add database migration".to_string(),
834 handle: None,
835 status: Status::Open,
836 priority: 2,
837 parent: None,
838 dependencies: vec![],
839 labels: vec![],
840 assignee: None,
841 updated_at: Utc::now(),
842 produces: vec![],
843 requires: vec![],
844 has_verify: false,
845 verify: None,
846 created_at: Utc::now(),
847 claimed_by: None,
848 attempts: 0,
849 paths: vec![],
850 kind: crate::unit::UnitType::Task,
851 feature: false,
852 has_decisions: false,
853 },
854 ],
855 };
856
857 let matches = find_similar_titles(&index, "Fix auth timeout handling", 0.7);
858 assert_eq!(matches.len(), 1);
859 assert_eq!(matches[0].id, "1");
860 }
861
862 #[test]
863 fn find_similar_skips_closed_units() {
864 use crate::index::{Index, IndexEntry};
865 use chrono::Utc;
866
867 let index = Index {
868 units: vec![IndexEntry {
869 handle: None,
870 id: "1".to_string(),
871 title: "Fix auth timeout".to_string(),
872 status: Status::Closed,
873 priority: 2,
874 parent: None,
875 dependencies: vec![],
876 labels: vec![],
877 assignee: None,
878 updated_at: Utc::now(),
879 produces: vec![],
880 requires: vec![],
881 has_verify: false,
882 verify: None,
883 created_at: Utc::now(),
884 claimed_by: None,
885 attempts: 0,
886 paths: vec![],
887 kind: crate::unit::UnitType::Task,
888 feature: false,
889 has_decisions: false,
890 }],
891 };
892
893 let matches = find_similar_titles(&index, "Fix auth timeout", 0.7);
894 assert!(matches.is_empty());
895 }
896
897 #[test]
898 fn find_similar_returns_empty_when_no_match() {
899 use crate::index::{Index, IndexEntry};
900 use chrono::Utc;
901
902 let index = Index {
903 units: vec![IndexEntry {
904 handle: None,
905 id: "1".to_string(),
906 title: "Fix auth timeout".to_string(),
907 status: Status::Open,
908 priority: 2,
909 parent: None,
910 dependencies: vec![],
911 labels: vec![],
912 assignee: None,
913 updated_at: Utc::now(),
914 produces: vec![],
915 requires: vec![],
916 has_verify: false,
917 verify: None,
918 created_at: Utc::now(),
919 claimed_by: None,
920 attempts: 0,
921 paths: vec![],
922 kind: crate::unit::UnitType::Task,
923 feature: false,
924 has_decisions: false,
925 }],
926 };
927
928 let matches = find_similar_titles(&index, "Add database migration", 0.7);
929 assert!(matches.is_empty());
930 }
931}