Skip to main content

timebomb/
add.rs

1//! Logic for the `timebomb plant` subcommand.
2//!
3//! This module implements the core logic for inserting a timebomb fuse into a
4//! source file at a specific line. It is intentionally defined with primitive
5//! parameters so that it compiles independently of any `PlantArgs` struct
6//! changes in `cli.rs`.
7
8use crate::error::{Error, Result};
9use chrono::NaiveDate;
10use std::io::{self, BufRead, Write};
11use std::path::{Path, PathBuf};
12
13// ---------------------------------------------------------------------------
14// Public entry point
15// ---------------------------------------------------------------------------
16
17/// Core logic for `timebomb plant`.
18///
19/// All parameters are primitives so this compiles independently of `cli.rs`
20/// changes.
21///
22/// # Parameters
23/// - `target`   — `"path/to/file.rs:42"` when search is None; plain file path when search is Some
24/// - `tag`      — tag keyword, e.g. `"TODO"`
25/// - `owner`    — optional owner name
26/// - `date_str` — optional `"YYYY-MM-DD"` expiry date
27/// - `in_days`  — optional number of days from `today` until expiry
28/// - `yes`      — skip confirmation prompt when `true`
29/// - `message`  — annotation message text
30/// - `today`    — the current date (injected for testability)
31/// - `search`   — optional pattern; when Some, `target` is a plain file path
32#[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    // 1. Resolve file path and line number -----------------------------------
45    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    // 2. Resolve the expiry date ---------------------------------------------
74    let expiry = resolve_date(date_str, in_days, today, yes)?;
75
76    // Warn if the date is already in the past — the fuse will immediately detonate.
77    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    // 3. Detect comment style ------------------------------------------------
85    let prefix = detect_comment_style(&file_path);
86
87    // 4. Build the annotation string -----------------------------------------
88    let annotation = build_annotation(prefix, tag, expiry, owner, message);
89
90    // 5. Read the file -------------------------------------------------------
91    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    // 6. Validate line number ------------------------------------------------
98    let lines: Vec<&str> = content.lines().collect();
99    let line_count = lines.len();
100
101    // line_number is 1-based; allow inserting at line_count + 1 (append)
102    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    // 7. Build the new file content ------------------------------------------
114    let mut new_content = insert_line(&lines, line_number, &annotation);
115    // insert_line always appends a trailing newline; strip it if the original
116    // file did not have one so we don't alter the file's newline convention.
117    if !had_trailing_newline {
118        new_content.pop();
119    }
120
121    // 8. Print a diff --------------------------------------------------------
122    println!("+ {}:{}  {}", file_path.display(), line_number, annotation);
123
124    // 9. Prompt for confirmation (unless --yes) ------------------------------
125    if !yes {
126        print!("Write change? [y/N]: ");
127        // Flush so the prompt appears before we block on stdin.
128        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            // User cancelled — not an error.
146            return Ok(0);
147        }
148    }
149
150    // 10. Write the file atomically ------------------------------------------
151    // Write to a sibling temp file then rename so a mid-write crash never
152    // leaves a partially-written source file.
153    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    // 11. Print confirmation -------------------------------------------------
164    println!("wrote {}:{}", file_path.display(), line_number);
165
166    // 12. Return success -----------------------------------------------------
167    Ok(0)
168}
169
170// ---------------------------------------------------------------------------
171// Helper: parse_target
172// ---------------------------------------------------------------------------
173
174/// Parse `"path/to/file.rs:42"` into `(PathBuf, usize)`.
175///
176/// Accepts optional column and trailing editor context after the line number:
177/// - `src/foo.rs:42`
178/// - `src/foo.rs:42:7`
179/// - `src/foo.rs:42:7: some editor context`
180///
181/// Splits on the *last* `:` so that Windows absolute paths (`C:\foo\bar.rs:5`)
182/// are handled correctly as long as the user puts the colon-number at the end.
183pub fn parse_target(target: &str) -> Result<(PathBuf, usize)> {
184    // Find the last colon
185    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    // Check if the last segment is purely digits (possibly with trailing spaces)
195    // If it is, it might be a column number — look back further.
196    if last_segment.trim().chars().all(|c| c.is_ascii_digit()) && !last_segment.trim().is_empty() {
197        // Could be file:line or file:line:col
198        // Check if there is another colon before this position
199        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 is also purely digits, treat it as the line number
203            // and last_segment as the column
204            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        // Fall through: the last segment is a line number (no col present)
235    } else if !last_segment.trim().is_empty() {
236        // Last segment starts with digits but has trailing non-digit content
237        // e.g. "42:7: some editor context" — walk back to find the line number
238        // Actually handle: "file:42:7: some text" where last colon is before "some text"
239        // but last_segment is not purely digits.
240        // Try: strip trailing text after space/colon, find line number in second-to-last numeric segment
241        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                // prev_segment is purely digits — check if the segment before it is also digits (col)
248                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                        // file:line:col: trailing text
255                        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                // prev_segment is line, last_segment is trailing text (not digits)
272                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    // Standard file:line parsing
290    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
317// ---------------------------------------------------------------------------
318// Helper: find_matching_lines
319// ---------------------------------------------------------------------------
320
321/// Scan a file line-by-line and return all (1-based line number, line content)
322/// pairs where the line contains `pattern` as a substring (case-sensitive).
323pub 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
339// ---------------------------------------------------------------------------
340// Helper: resolve_date
341// ---------------------------------------------------------------------------
342
343/// Resolve the expiry `NaiveDate` from the `--date` or `--in-days` arguments.
344///
345/// When both are None:
346/// - If `yes` is true: default to 90 days silently (prints a notice)
347/// - If `yes` is false: prompt the user for a number of days (default 90)
348pub 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
408// ---------------------------------------------------------------------------
409// Helper: detect_comment_style
410// ---------------------------------------------------------------------------
411
412/// Return the comment prefix appropriate for the given file extension.
413///
414/// | Prefix | Extensions                                                            |
415/// |--------|-----------------------------------------------------------------------|
416/// | `//`   | rs, go, ts, js, jsx, tsx, java, swift, c, cpp, cc, cs, kt            |
417/// | `#`    | py, rb, sh, bash, zsh, yaml, yml, tf, toml, r                        |
418/// | `--`   | sql, lua, hs                                                          |
419/// | `//`   | anything else (default)                                               |
420pub 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        // C-style line comments
429        "rs" | "go" | "ts" | "js" | "jsx" | "tsx" | "java" | "swift" | "c" | "cpp" | "cc"
430        | "cs" | "kt" => "//",
431
432        // Hash-style comments
433        "py" | "rb" | "sh" | "bash" | "zsh" | "yaml" | "yml" | "tf" | "toml" | "r" => "#",
434
435        // Double-dash comments
436        "sql" | "lua" | "hs" => "--",
437
438        // Default to C-style
439        _ => "//",
440    }
441}
442
443// ---------------------------------------------------------------------------
444// Helper: build_annotation
445// ---------------------------------------------------------------------------
446
447/// Build the full annotation string.
448///
449/// Without owner: `{prefix} {TAG}[{YYYY-MM-DD}]: {message}`
450/// With owner:    `{prefix} {TAG}[{YYYY-MM-DD}][{owner}]: {message}`
451pub 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
466// ---------------------------------------------------------------------------
467// Helper: insert_line
468// ---------------------------------------------------------------------------
469
470/// Insert `new_line` *before* the 1-based `line_number` in `lines`, returning
471/// the complete new file content as a `String`.
472///
473/// Inserting at `line_number == lines.len() + 1` appends after the last line.
474///
475/// The returned string always ends with a newline.
476pub fn insert_line(lines: &[&str], line_number: usize, new_line: &str) -> String {
477    // Convert to owned strings so we can splice freely.
478    let mut owned: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
479
480    // line_number is 1-based; insert at index (line_number - 1).
481    let insert_at = line_number - 1;
482    owned.insert(insert_at, new_line.to_string());
483
484    // Join with newlines and ensure trailing newline.
485    let mut result = owned.join("\n");
486    result.push('\n');
487    result
488}
489
490// ---------------------------------------------------------------------------
491// Tests
492// ---------------------------------------------------------------------------
493
494#[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    // -- detect_comment_style ------------------------------------------------
508
509    #[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    // -- build_annotation ----------------------------------------------------
567
568    #[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    // -- parse_target --------------------------------------------------------
606
607    #[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        // file:line:col — should return line=42
652        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        // file:line:col: trailing message — should return line=42
660        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    // -- resolve_date --------------------------------------------------------
666
667    #[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        // When neither --date nor --in-days and yes=true, defaults to 90 days
691        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        // Neither --date nor --in-days, yes=true → defaults to 90 days
714        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    // -- insert_line ---------------------------------------------------------
733
734    #[test]
735    fn test_insert_line_middle() {
736        // 3-line file, insert at line 2 → new line becomes line 2, old line 2 → line 3
737        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        // Inserting at line N+1 appends
762        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    // -- find_matching_lines -------------------------------------------------
787
788    #[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    // -- run_add (integration tests using tempfile) --------------------------
820
821    #[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        // With yes=true and no date/in_days, should default to 90 days (not error)
841        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, // --yes: skip prompt
888            "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        // date should be today + 30 days = 2025-07-01
929        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        // line count is 2; line 3 = append
939        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}