1use crate::error::{Error, Result};
9use chrono::NaiveDate;
10use std::io::{self, BufRead, Write};
11use std::path::{Path, PathBuf};
12
13#[allow(clippy::too_many_arguments)]
33pub fn run_add(
34 target: &str,
35 tag: &str,
36 owner: Option<&str>,
37 date_str: Option<&str>,
38 in_days: Option<u32>,
39 yes: bool,
40 message: &str,
41 today: NaiveDate,
42 search: Option<&str>,
43) -> Result<i32> {
44 let (file_path, line_number) = if let Some(pattern) = search {
46 let path = PathBuf::from(target);
47 let matches = find_matching_lines(&path, pattern)?;
48 match matches.len() {
49 0 => {
50 return Err(Error::InvalidArgument(format!(
51 "no lines matching '{}' found in {}",
52 pattern, target
53 )));
54 }
55 1 => {
56 println!("matched line {}: {}", matches[0].0, matches[0].1.trim_end());
57 (path, matches[0].0)
58 }
59 n => {
60 let mut detail =
61 format!("pattern '{}' matched {} lines in {}:", pattern, n, target);
62 for (ln, content) in &matches {
63 detail.push_str(&format!("\n line {}: {}", ln, content.trim_end()));
64 }
65 detail.push_str("\nuse FILE:LINE to be specific");
66 return Err(Error::InvalidArgument(detail));
67 }
68 }
69 } else {
70 parse_target(target)?
71 };
72
73 let expiry = resolve_date(date_str, in_days, today, yes)?;
75
76 if expiry < today {
78 eprintln!(
79 "warning: expiry date {} is already in the past — this fuse will detonate immediately",
80 expiry.format("%Y-%m-%d")
81 );
82 }
83
84 let prefix = detect_comment_style(&file_path);
86
87 let annotation = build_annotation(prefix, tag, expiry, owner, message);
89
90 let content = std::fs::read_to_string(&file_path).map_err(|e| Error::Io {
92 source: e,
93 path: Some(file_path.clone()),
94 })?;
95 let had_trailing_newline = content.ends_with('\n');
96
97 let lines: Vec<&str> = content.lines().collect();
99 let line_count = lines.len();
100
101 if line_number < 1 || line_number > line_count + 1 {
103 return Err(Error::InvalidArgument(format!(
104 "line number {} is out of range for '{}' ({} lines); \
105 must be between 1 and {}",
106 line_number,
107 file_path.display(),
108 line_count,
109 line_count + 1,
110 )));
111 }
112
113 let mut new_content = insert_line(&lines, line_number, &annotation);
115 if !had_trailing_newline {
118 new_content.pop();
119 }
120
121 println!("+ {}:{} {}", file_path.display(), line_number, annotation);
123
124 if !yes {
126 print!("Write change? [y/N]: ");
127 io::stdout().flush().map_err(|e| Error::Io {
129 source: e,
130 path: None,
131 })?;
132
133 let stdin = io::stdin();
134 let mut line_buf = String::new();
135 stdin
136 .lock()
137 .read_line(&mut line_buf)
138 .map_err(|e| Error::Io {
139 source: e,
140 path: None,
141 })?;
142
143 let response = line_buf.trim();
144 if response != "y" && response != "Y" {
145 return Ok(0);
147 }
148 }
149
150 let tmp_path = file_path.with_extension(format!("tmp.{}", std::process::id()));
154 std::fs::write(&tmp_path, &new_content).map_err(|e| Error::Io {
155 source: e,
156 path: Some(tmp_path.clone()),
157 })?;
158 std::fs::rename(&tmp_path, &file_path).map_err(|e| Error::Io {
159 source: e,
160 path: Some(file_path.clone()),
161 })?;
162
163 println!("wrote {}:{}", file_path.display(), line_number);
165
166 Ok(0)
168}
169
170pub fn parse_target(target: &str) -> Result<(PathBuf, usize)> {
184 let last_colon = target.rfind(':').ok_or_else(|| {
186 Error::InvalidArgument(format!(
187 "target '{}' must be in the form 'file:LINE' (e.g. src/main.rs:42)",
188 target
189 ))
190 })?;
191
192 let last_segment = &target[last_colon + 1..];
193
194 if last_segment.trim().chars().all(|c| c.is_ascii_digit()) && !last_segment.trim().is_empty() {
197 let before_last = &target[..last_colon];
200 if let Some(prev_colon) = before_last.rfind(':') {
201 let prev_segment = &before_last[prev_colon + 1..];
202 if prev_segment.trim().chars().all(|c| c.is_ascii_digit())
205 && !prev_segment.trim().is_empty()
206 {
207 let file_part = &before_last[..prev_colon];
208 let line_part = prev_segment.trim();
209
210 if file_part.is_empty() {
211 return Err(Error::InvalidArgument(format!(
212 "target '{}': file path is empty",
213 target
214 )));
215 }
216
217 let line_number: usize = line_part.parse().map_err(|_| {
218 Error::InvalidArgument(format!(
219 "target '{}': '{}' is not a valid line number",
220 target, line_part
221 ))
222 })?;
223
224 if line_number == 0 {
225 return Err(Error::InvalidArgument(format!(
226 "target '{}': line number must be >= 1",
227 target
228 )));
229 }
230
231 return Ok((PathBuf::from(file_part), line_number));
232 }
233 }
234 } else if !last_segment.trim().is_empty() {
236 let before_last = &target[..last_colon];
242 if let Some(prev_colon) = before_last.rfind(':') {
243 let prev_segment = &before_last[prev_colon + 1..];
244 if prev_segment.trim().chars().all(|c| c.is_ascii_digit())
245 && !prev_segment.trim().is_empty()
246 {
247 let before_prev = &before_last[..prev_colon];
249 if let Some(pp_colon) = before_prev.rfind(':') {
250 let pp_segment = &before_prev[pp_colon + 1..];
251 if pp_segment.trim().chars().all(|c| c.is_ascii_digit())
252 && !pp_segment.trim().is_empty()
253 {
254 let file_part = &before_prev[..pp_colon];
256 let line_part = pp_segment.trim();
257
258 if !file_part.is_empty() {
259 let line_number: usize = line_part.parse().map_err(|_| {
260 Error::InvalidArgument(format!(
261 "target '{}': '{}' is not a valid line number",
262 target, line_part
263 ))
264 })?;
265 if line_number > 0 {
266 return Ok((PathBuf::from(file_part), line_number));
267 }
268 }
269 }
270 }
271 let file_part = &before_prev;
273 let line_part = prev_segment.trim();
274 if !file_part.is_empty() {
275 let line_number: usize = line_part.parse().map_err(|_| {
276 Error::InvalidArgument(format!(
277 "target '{}': '{}' is not a valid line number",
278 target, line_part
279 ))
280 })?;
281 if line_number > 0 {
282 return Ok((PathBuf::from(*file_part), line_number));
283 }
284 }
285 }
286 }
287 }
288
289 let file_part = &target[..last_colon];
291 let line_part = last_segment.trim();
292
293 if file_part.is_empty() {
294 return Err(Error::InvalidArgument(format!(
295 "target '{}': file path is empty",
296 target
297 )));
298 }
299
300 let line_number: usize = line_part.parse().map_err(|_| {
301 Error::InvalidArgument(format!(
302 "target '{}': '{}' is not a valid line number",
303 target, line_part
304 ))
305 })?;
306
307 if line_number == 0 {
308 return Err(Error::InvalidArgument(format!(
309 "target '{}': line number must be >= 1",
310 target
311 )));
312 }
313
314 Ok((PathBuf::from(file_part), line_number))
315}
316
317pub fn find_matching_lines(file: &Path, pattern: &str) -> Result<Vec<(usize, String)>> {
324 let content = std::fs::read_to_string(file).map_err(|e| Error::Io {
325 source: e,
326 path: Some(file.to_path_buf()),
327 })?;
328
329 let matches = content
330 .lines()
331 .enumerate()
332 .filter(|(_, line)| line.contains(pattern))
333 .map(|(i, line)| (i + 1, line.to_string()))
334 .collect();
335
336 Ok(matches)
337}
338
339pub fn resolve_date(
349 date_str: Option<&str>,
350 in_days: Option<u32>,
351 today: NaiveDate,
352 yes: bool,
353) -> Result<NaiveDate> {
354 match (date_str, in_days) {
355 (Some(s), _) => NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| {
356 Error::InvalidArgument(format!("'{}' is not a valid date — expected YYYY-MM-DD", s))
357 }),
358 (None, Some(days)) => today
359 .checked_add_signed(chrono::Duration::days(days as i64))
360 .ok_or_else(|| {
361 Error::InvalidArgument(format!("--in-days {} overflows the calendar", days))
362 }),
363 (None, None) => {
364 let days: u32 = if yes {
365 let default_date = today
366 .checked_add_signed(chrono::Duration::days(90))
367 .ok_or_else(|| {
368 Error::InvalidArgument("90-day default overflows the calendar".to_string())
369 })?;
370 println!(
371 "No expiry specified; defaulting to 90 days from today ({})",
372 default_date.format("%Y-%m-%d")
373 );
374 90
375 } else {
376 print!("Expire in how many days? [90]: ");
377 io::stdout().flush().map_err(|e| Error::Io {
378 source: e,
379 path: None,
380 })?;
381 let stdin = io::stdin();
382 let mut buf = String::new();
383 stdin.lock().read_line(&mut buf).map_err(|e| Error::Io {
384 source: e,
385 path: None,
386 })?;
387 let trimmed = buf.trim();
388 if trimmed.is_empty() {
389 90
390 } else {
391 trimmed.parse::<u32>().map_err(|_| {
392 Error::InvalidArgument(format!(
393 "'{}' is not a valid number of days",
394 trimmed
395 ))
396 })?
397 }
398 };
399 today
400 .checked_add_signed(chrono::Duration::days(days as i64))
401 .ok_or_else(|| {
402 Error::InvalidArgument(format!("--in-days {} overflows the calendar", days))
403 })
404 }
405 }
406}
407
408pub fn detect_comment_style(path: &std::path::Path) -> &'static str {
421 let ext = path
422 .extension()
423 .and_then(|e| e.to_str())
424 .unwrap_or("")
425 .to_lowercase();
426
427 match ext.as_str() {
428 "rs" | "go" | "ts" | "js" | "jsx" | "tsx" | "java" | "swift" | "c" | "cpp" | "cc"
430 | "cs" | "kt" => "//",
431
432 "py" | "rb" | "sh" | "bash" | "zsh" | "yaml" | "yml" | "tf" | "toml" | "r" => "#",
434
435 "sql" | "lua" | "hs" => "--",
437
438 _ => "//",
440 }
441}
442
443pub fn build_annotation(
452 prefix: &str,
453 tag: &str,
454 expiry: NaiveDate,
455 owner: Option<&str>,
456 message: &str,
457) -> String {
458 let tag_upper = tag.to_uppercase();
459 let date_str = expiry.format("%Y-%m-%d");
460 match owner {
461 None => format!("{} {}[{}]: {}", prefix, tag_upper, date_str, message),
462 Some(o) => format!("{} {}[{}][{}]: {}", prefix, tag_upper, date_str, o, message),
463 }
464}
465
466pub fn insert_line(lines: &[&str], line_number: usize, new_line: &str) -> String {
477 let mut owned: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
479
480 let insert_at = line_number - 1;
482 owned.insert(insert_at, new_line.to_string());
483
484 let mut result = owned.join("\n");
486 result.push('\n');
487 result
488}
489
490#[cfg(test)]
495mod tests {
496 use super::*;
497 use chrono::NaiveDate;
498
499 fn date(s: &str) -> NaiveDate {
500 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
501 }
502
503 fn today() -> NaiveDate {
504 NaiveDate::from_ymd_opt(2026, 3, 22).unwrap()
505 }
506
507 #[test]
510 fn test_detect_comment_style_rs() {
511 assert_eq!(
512 detect_comment_style(std::path::Path::new("src/main.rs")),
513 "//"
514 );
515 }
516
517 #[test]
518 fn test_detect_comment_style_py() {
519 assert_eq!(detect_comment_style(std::path::Path::new("script.py")), "#");
520 }
521
522 #[test]
523 fn test_detect_comment_style_sql() {
524 assert_eq!(
525 detect_comment_style(std::path::Path::new("schema.sql")),
526 "--"
527 );
528 }
529
530 #[test]
531 fn test_detect_comment_style_unknown() {
532 assert_eq!(detect_comment_style(std::path::Path::new("file.xyz")), "//");
533 }
534
535 #[test]
536 fn test_detect_comment_style_no_extension() {
537 assert_eq!(detect_comment_style(std::path::Path::new("Makefile")), "//");
538 }
539
540 #[test]
541 fn test_detect_comment_style_go() {
542 assert_eq!(detect_comment_style(std::path::Path::new("main.go")), "//");
543 }
544
545 #[test]
546 fn test_detect_comment_style_yaml() {
547 assert_eq!(
548 detect_comment_style(std::path::Path::new("config.yaml")),
549 "#"
550 );
551 }
552
553 #[test]
554 fn test_detect_comment_style_lua() {
555 assert_eq!(detect_comment_style(std::path::Path::new("init.lua")), "--");
556 }
557
558 #[test]
559 fn test_detect_comment_style_toml() {
560 assert_eq!(
561 detect_comment_style(std::path::Path::new("Cargo.toml")),
562 "#"
563 );
564 }
565
566 #[test]
569 fn test_build_annotation_no_owner() {
570 let expiry = date("2026-09-01");
571 let result = build_annotation("//", "todo", expiry, None, "remove legacy oauth flow");
572 assert_eq!(result, "// TODO[2026-09-01]: remove legacy oauth flow");
573 }
574
575 #[test]
576 fn test_build_annotation_with_owner() {
577 let expiry = date("2026-09-01");
578 let result = build_annotation(
579 "//",
580 "TODO",
581 expiry,
582 Some("alice"),
583 "remove legacy oauth flow",
584 );
585 assert_eq!(
586 result,
587 "// TODO[2026-09-01][alice]: remove legacy oauth flow"
588 );
589 }
590
591 #[test]
592 fn test_build_annotation_tag_uppercased() {
593 let expiry = date("2027-01-15");
594 let result = build_annotation("#", "fixme", expiry, None, "cleanup");
595 assert_eq!(result, "# FIXME[2027-01-15]: cleanup");
596 }
597
598 #[test]
599 fn test_build_annotation_sql_prefix() {
600 let expiry = date("2025-12-31");
601 let result = build_annotation("--", "HACK", expiry, Some("bob"), "temp workaround");
602 assert_eq!(result, "-- HACK[2025-12-31][bob]: temp workaround");
603 }
604
605 #[test]
608 fn test_parse_target_valid() {
609 let (path, line) = parse_target("src/foo.rs:42").unwrap();
610 assert_eq!(path, PathBuf::from("src/foo.rs"));
611 assert_eq!(line, 42);
612 }
613
614 #[test]
615 fn test_parse_target_valid_nested() {
616 let (path, line) = parse_target("a/b/c/main.go:1").unwrap();
617 assert_eq!(path, PathBuf::from("a/b/c/main.go"));
618 assert_eq!(line, 1);
619 }
620
621 #[test]
622 fn test_parse_target_invalid_no_colon() {
623 let result = parse_target("src/foo.rs");
624 assert!(result.is_err());
625 let msg = format!("{}", result.unwrap_err());
626 assert!(msg.contains("FILE:LINE") || msg.contains("file:LINE") || msg.contains("form"));
627 }
628
629 #[test]
630 fn test_parse_target_invalid_line_zero() {
631 let result = parse_target("src/foo.rs:0");
632 assert!(result.is_err());
633 let msg = format!("{}", result.unwrap_err());
634 assert!(msg.contains("1") || msg.contains("zero") || msg.contains(">="));
635 }
636
637 #[test]
638 fn test_parse_target_invalid_non_numeric_line() {
639 let result = parse_target("src/foo.rs:abc");
640 assert!(result.is_err());
641 }
642
643 #[test]
644 fn test_parse_target_empty_file() {
645 let result = parse_target(":42");
646 assert!(result.is_err());
647 }
648
649 #[test]
650 fn test_parse_target_accepts_col() {
651 let (path, line) = parse_target("src/foo.rs:42:7").unwrap();
653 assert_eq!(path, PathBuf::from("src/foo.rs"));
654 assert_eq!(line, 42);
655 }
656
657 #[test]
658 fn test_parse_target_accepts_col_and_message() {
659 let (path, line) = parse_target("src/foo.rs:42:7: some editor context").unwrap();
661 assert_eq!(path, PathBuf::from("src/foo.rs"));
662 assert_eq!(line, 42);
663 }
664
665 #[test]
668 fn test_resolve_date_from_date_str() {
669 let t = date("2025-06-01");
670 let result = resolve_date(Some("2026-09-01"), None, t, true).unwrap();
671 assert_eq!(result, date("2026-09-01"));
672 }
673
674 #[test]
675 fn test_resolve_date_from_in_days() {
676 let t = date("2025-06-01");
677 let result = resolve_date(None, Some(90), t, true).unwrap();
678 assert_eq!(result, date("2025-08-30"));
679 }
680
681 #[test]
682 fn test_resolve_date_in_days_zero() {
683 let t = date("2025-06-01");
684 let result = resolve_date(None, Some(0), t, true).unwrap();
685 assert_eq!(result, t);
686 }
687
688 #[test]
689 fn test_resolve_date_neither_yes_defaults_90() {
690 let t = today();
692 let result = resolve_date(None, None, t, true).unwrap();
693 let expected = t.checked_add_signed(chrono::Duration::days(90)).unwrap();
694 assert_eq!(result, expected);
695 }
696
697 #[test]
698 fn test_resolve_date_prefers_date_str_over_in_days() {
699 let t = date("2025-06-01");
700 let result = resolve_date(Some("2099-01-01"), Some(5), t, true).unwrap();
701 assert_eq!(result, date("2099-01-01"));
702 }
703
704 #[test]
705 fn test_resolve_date_invalid_format() {
706 let t = date("2025-06-01");
707 let result = resolve_date(Some("01-09-2026"), None, t, true);
708 assert!(result.is_err());
709 }
710
711 #[test]
712 fn test_run_add_default_days_yes() {
713 let dir = tempfile::tempdir().unwrap();
715 let file = dir.path().join("test.rs");
716 std::fs::write(&file, "fn main() {}\n").unwrap();
717 let target = format!("{}:1", file.display());
718
719 let t = today();
720 let result = run_add(&target, "TODO", None, None, None, true, "msg", t, None);
721 assert!(result.is_ok());
722
723 let written = std::fs::read_to_string(&file).unwrap();
724 let expected_date = t
725 .checked_add_signed(chrono::Duration::days(90))
726 .unwrap()
727 .format("%Y-%m-%d")
728 .to_string();
729 assert!(written.contains(&expected_date));
730 }
731
732 #[test]
735 fn test_insert_line_middle() {
736 let lines = vec!["line one", "line two", "line three"];
738 let result = insert_line(&lines, 2, "// TODO[2026-01-01]: new annotation");
739 let result_lines: Vec<&str> = result.lines().collect();
740 assert_eq!(result_lines.len(), 4);
741 assert_eq!(result_lines[0], "line one");
742 assert_eq!(result_lines[1], "// TODO[2026-01-01]: new annotation");
743 assert_eq!(result_lines[2], "line two");
744 assert_eq!(result_lines[3], "line three");
745 }
746
747 #[test]
748 fn test_insert_line_first() {
749 let lines = vec!["first", "second", "third"];
750 let result = insert_line(&lines, 1, "// annotation");
751 let result_lines: Vec<&str> = result.lines().collect();
752 assert_eq!(result_lines.len(), 4);
753 assert_eq!(result_lines[0], "// annotation");
754 assert_eq!(result_lines[1], "first");
755 assert_eq!(result_lines[2], "second");
756 assert_eq!(result_lines[3], "third");
757 }
758
759 #[test]
760 fn test_insert_line_after_last() {
761 let lines = vec!["alpha", "beta", "gamma"];
763 let result = insert_line(&lines, 4, "// appended");
764 let result_lines: Vec<&str> = result.lines().collect();
765 assert_eq!(result_lines.len(), 4);
766 assert_eq!(result_lines[3], "// appended");
767 }
768
769 #[test]
770 fn test_insert_line_single_line_file() {
771 let lines = vec!["only line"];
772 let result = insert_line(&lines, 1, "// before");
773 let result_lines: Vec<&str> = result.lines().collect();
774 assert_eq!(result_lines.len(), 2);
775 assert_eq!(result_lines[0], "// before");
776 assert_eq!(result_lines[1], "only line");
777 }
778
779 #[test]
780 fn test_insert_line_trailing_newline() {
781 let lines = vec!["a", "b"];
782 let result = insert_line(&lines, 1, "x");
783 assert!(result.ends_with('\n'), "result should end with a newline");
784 }
785
786 #[test]
789 fn test_find_matching_lines_found() {
790 let dir = tempfile::tempdir().unwrap();
791 let file = dir.path().join("test.rs");
792 std::fs::write(&file, "line one\ncontains_pattern here\nline three\n").unwrap();
793 let matches = find_matching_lines(&file, "contains_pattern").unwrap();
794 assert_eq!(matches.len(), 1);
795 assert_eq!(matches[0].0, 2);
796 assert!(matches[0].1.contains("contains_pattern"));
797 }
798
799 #[test]
800 fn test_find_matching_lines_multiple() {
801 let dir = tempfile::tempdir().unwrap();
802 let file = dir.path().join("test.rs");
803 std::fs::write(&file, "foo bar\nfoo baz\nno match\n").unwrap();
804 let matches = find_matching_lines(&file, "foo").unwrap();
805 assert_eq!(matches.len(), 2);
806 assert_eq!(matches[0].0, 1);
807 assert_eq!(matches[1].0, 2);
808 }
809
810 #[test]
811 fn test_find_matching_lines_none() {
812 let dir = tempfile::tempdir().unwrap();
813 let file = dir.path().join("test.rs");
814 std::fs::write(&file, "line one\nline two\n").unwrap();
815 let matches = find_matching_lines(&file, "zzz_no_match").unwrap();
816 assert_eq!(matches.len(), 0);
817 }
818
819 #[test]
822 fn test_run_add_invalid_target_no_colon() {
823 let t = date("2025-06-01");
824 let result = run_add(
825 "src/nocoton",
826 "TODO",
827 None,
828 Some("2026-01-01"),
829 None,
830 true,
831 "msg",
832 t,
833 None,
834 );
835 assert!(result.is_err());
836 }
837
838 #[test]
839 fn test_run_add_missing_date_and_in_days_yes_defaults() {
840 let dir = tempfile::tempdir().unwrap();
842 let file = dir.path().join("test.rs");
843 std::fs::write(&file, "fn main() {}\n").unwrap();
844 let target = format!("{}:1", file.display());
845
846 let t = date("2025-06-01");
847 let result = run_add(&target, "TODO", None, None, None, true, "msg", t, None);
848 assert!(result.is_ok());
849 }
850
851 #[test]
852 fn test_run_add_line_out_of_range() {
853 let dir = tempfile::tempdir().unwrap();
854 let file = dir.path().join("test.rs");
855 std::fs::write(&file, "fn main() {}\n").unwrap();
856 let target = format!("{}:999", file.display());
857
858 let t = date("2025-06-01");
859 let result = run_add(
860 &target,
861 "TODO",
862 None,
863 Some("2026-01-01"),
864 None,
865 true,
866 "msg",
867 t,
868 None,
869 );
870 assert!(result.is_err());
871 }
872
873 #[test]
874 fn test_run_add_inserts_annotation_with_yes() {
875 let dir = tempfile::tempdir().unwrap();
876 let file = dir.path().join("test.rs");
877 std::fs::write(&file, "fn foo() {}\nfn bar() {}\n").unwrap();
878 let target = format!("{}:1", file.display());
879
880 let t = date("2025-06-01");
881 let result = run_add(
882 &target,
883 "TODO",
884 None,
885 Some("2026-09-01"),
886 None,
887 true, "remove foo after migration",
889 t,
890 None,
891 );
892 assert!(result.is_ok());
893 assert_eq!(result.unwrap(), 0);
894
895 let written = std::fs::read_to_string(&file).unwrap();
896 let lines: Vec<&str> = written.lines().collect();
897 assert_eq!(lines.len(), 3);
898 assert_eq!(lines[0], "// TODO[2026-09-01]: remove foo after migration");
899 assert_eq!(lines[1], "fn foo() {}");
900 assert_eq!(lines[2], "fn bar() {}");
901 }
902
903 #[test]
904 fn test_run_add_inserts_annotation_with_owner() {
905 let dir = tempfile::tempdir().unwrap();
906 let file = dir.path().join("test.py");
907 std::fs::write(&file, "def foo():\n pass\n").unwrap();
908 let target = format!("{}:2", file.display());
909
910 let t = date("2025-06-01");
911 let result = run_add(
912 &target,
913 "FIXME",
914 Some("alice"),
915 None,
916 Some(30),
917 true,
918 "clean this up",
919 t,
920 None,
921 );
922 assert!(result.is_ok());
923
924 let written = std::fs::read_to_string(&file).unwrap();
925 let lines: Vec<&str> = written.lines().collect();
926 assert_eq!(lines.len(), 3);
927 assert_eq!(lines[0], "def foo():");
928 assert_eq!(lines[1], "# FIXME[2025-07-01][alice]: clean this up");
930 assert_eq!(lines[2], " pass");
931 }
932
933 #[test]
934 fn test_run_add_append_after_last_line() {
935 let dir = tempfile::tempdir().unwrap();
936 let file = dir.path().join("test.rs");
937 std::fs::write(&file, "line1\nline2\n").unwrap();
938 let target = format!("{}:3", file.display());
940
941 let t = date("2025-06-01");
942 let result = run_add(
943 &target,
944 "TODO",
945 None,
946 Some("2027-01-01"),
947 None,
948 true,
949 "appended",
950 t,
951 None,
952 );
953 assert!(result.is_ok());
954
955 let written = std::fs::read_to_string(&file).unwrap();
956 let lines: Vec<&str> = written.lines().collect();
957 assert_eq!(lines.len(), 3);
958 assert_eq!(lines[2], "// TODO[2027-01-01]: appended");
959 }
960
961 #[test]
962 fn test_run_add_nonexistent_file_returns_io_error() {
963 let t = date("2025-06-01");
964 let result = run_add(
965 "/nonexistent/path/file.rs:1",
966 "TODO",
967 None,
968 Some("2026-01-01"),
969 None,
970 true,
971 "msg",
972 t,
973 None,
974 );
975 assert!(result.is_err());
976 match result.unwrap_err() {
977 Error::Io { .. } => {}
978 other => panic!("expected Io error, got: {:?}", other),
979 }
980 }
981
982 #[test]
983 fn test_run_add_with_search_single_match() {
984 let dir = tempfile::tempdir().unwrap();
985 let file = dir.path().join("test.rs");
986 std::fs::write(&file, "fn alpha() {}\nfn legacy_auth() {}\nfn gamma() {}\n").unwrap();
987
988 let t = today();
989 let result = run_add(
990 file.to_str().unwrap(),
991 "TODO",
992 None,
993 Some("2027-01-01"),
994 None,
995 true,
996 "remove legacy auth",
997 t,
998 Some("legacy_auth"),
999 );
1000 assert!(result.is_ok());
1001
1002 let written = std::fs::read_to_string(&file).unwrap();
1003 assert!(written.contains("TODO[2027-01-01]: remove legacy auth"));
1004 }
1005
1006 #[test]
1007 fn test_run_add_with_search_no_match() {
1008 let dir = tempfile::tempdir().unwrap();
1009 let file = dir.path().join("test.rs");
1010 std::fs::write(&file, "fn alpha() {}\nfn beta() {}\n").unwrap();
1011
1012 let t = today();
1013 let result = run_add(
1014 file.to_str().unwrap(),
1015 "TODO",
1016 None,
1017 Some("2027-01-01"),
1018 None,
1019 true,
1020 "msg",
1021 t,
1022 Some("zzz_no_match"),
1023 );
1024 assert!(result.is_err());
1025 let msg = result.unwrap_err().to_string();
1026 assert!(msg.contains("no lines matching"));
1027 }
1028
1029 #[test]
1030 fn test_run_add_with_search_multiple_matches() {
1031 let dir = tempfile::tempdir().unwrap();
1032 let file = dir.path().join("test.rs");
1033 std::fs::write(&file, "fn foo_a() {}\nfn foo_b() {}\nfn bar() {}\n").unwrap();
1034
1035 let t = today();
1036 let result = run_add(
1037 file.to_str().unwrap(),
1038 "TODO",
1039 None,
1040 Some("2027-01-01"),
1041 None,
1042 true,
1043 "msg",
1044 t,
1045 Some("foo"),
1046 );
1047 assert!(result.is_err());
1048 let msg = result.unwrap_err().to_string();
1049 assert!(msg.contains("matched") || msg.contains("2 lines"));
1050 }
1051}