Skip to main content

retro_core/
util.rs

1use crate::errors::CoreError;
2use chrono::Utc;
3use std::fs::OpenOptions;
4use std::io::Write;
5use std::path::Path;
6
7/// Backup a file to the backup directory.
8/// Uses a sanitized path to avoid collisions between files with the same name
9/// in different directories (e.g., /proj-a/CLAUDE.md vs /proj-b/CLAUDE.md).
10pub fn backup_file(path: &str, backup_dir: &Path) -> Result<(), CoreError> {
11    if !Path::new(path).exists() {
12        return Ok(());
13    }
14
15    let sanitized = path
16        .replace(['/', '\\'], "_")
17        .trim_start_matches('_')
18        .to_string();
19
20    let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
21    let backup_path = backup_dir.join(format!("{sanitized}.{timestamp}.bak"));
22
23    std::fs::copy(path, &backup_path).map_err(|e| {
24        CoreError::Io(format!(
25            "backing up {} to {}: {e}",
26            path,
27            backup_path.display()
28        ))
29    })?;
30
31    Ok(())
32}
33
34/// Truncate a string at a valid UTF-8 char boundary. Never panics.
35pub fn truncate_str(s: &str, max: usize) -> &str {
36    if s.len() <= max {
37        return s;
38    }
39    let mut i = max;
40    while i > 0 && !s.is_char_boundary(i) {
41        i -= 1;
42    }
43    &s[..i]
44}
45
46/// Shorten a path for display: replace home directory prefix with `~`.
47pub fn shorten_path(path: &str) -> String {
48    if let Some(home) = std::env::var_os("HOME") {
49        let home_str = home.to_string_lossy();
50        if path.starts_with(home_str.as_ref()) {
51            return format!("~{}", &path[home_str.len()..]);
52        }
53    }
54    path.to_string()
55}
56
57/// Shorten a `Path` for display: replace home directory prefix with `~`.
58pub fn shorten_path_buf(path: &std::path::Path) -> String {
59    shorten_path(&path.display().to_string())
60}
61
62/// Log a parse warning to ~/.retro/warnings.log instead of stderr.
63/// Best-effort: silently drops the message if the file can't be opened.
64pub fn log_parse_warning(msg: &str) {
65    let log_path = crate::config::retro_dir().join("warnings.log");
66    if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) {
67        let ts = Utc::now().format("%Y-%m-%dT%H:%M:%S");
68        let _ = writeln!(file, "[{ts}] {msg}");
69    }
70}
71
72/// Strip markdown code fences from an AI response.
73/// Handles ```json, ```yaml, ```markdown, and bare ``` fences.
74/// Returns the inner content if fences are found, otherwise returns the input trimmed.
75pub fn strip_code_fences(content: &str) -> String {
76    let trimmed = content.trim();
77    if !trimmed.starts_with("```") {
78        return trimmed.to_string();
79    }
80
81    let lines: Vec<&str> = trimmed.lines().collect();
82    let mut result = Vec::new();
83    let mut in_block = false;
84
85    for line in lines {
86        if line.starts_with("```") && !in_block {
87            in_block = true;
88            continue;
89        }
90        if line.starts_with("```") && in_block {
91            break;
92        }
93        if in_block {
94            result.push(line);
95        }
96    }
97
98    if result.is_empty() {
99        trimmed.to_string()
100    } else {
101        result.join("\n")
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn test_strip_json_fences() {
111        let input = "```json\n{\"key\": \"value\"}\n```";
112        assert_eq!(strip_code_fences(input), "{\"key\": \"value\"}");
113    }
114
115    #[test]
116    fn test_strip_yaml_fences() {
117        let input = "```yaml\n---\nname: test\n---\nbody\n```";
118        assert_eq!(strip_code_fences(input), "---\nname: test\n---\nbody");
119    }
120
121    #[test]
122    fn test_strip_bare_fences() {
123        let input = "```\ncontent here\n```";
124        assert_eq!(strip_code_fences(input), "content here");
125    }
126
127    #[test]
128    fn test_no_fences() {
129        let input = "just plain text";
130        assert_eq!(strip_code_fences(input), "just plain text");
131    }
132
133    #[test]
134    fn test_whitespace_trimmed() {
135        let input = "  \n```json\n{}\n```\n  ";
136        assert_eq!(strip_code_fences(input), "{}");
137    }
138
139    #[test]
140    fn test_truncate_str_ascii() {
141        assert_eq!(truncate_str("hello world", 5), "hello");
142    }
143
144    #[test]
145    fn test_truncate_str_no_truncation() {
146        assert_eq!(truncate_str("short", 100), "short");
147    }
148
149    #[test]
150    fn test_truncate_str_utf8_boundary() {
151        // "café" is 5 bytes: c(1) a(1) f(1) é(2)
152        let s = "caf\u{00e9}!";
153        // Truncating at byte 4 would land mid-é, should walk back to 3
154        assert_eq!(truncate_str(s, 4), "caf");
155    }
156
157    #[test]
158    fn test_truncate_str_empty() {
159        assert_eq!(truncate_str("", 10), "");
160    }
161
162    #[test]
163    fn test_shorten_path_replaces_home() {
164        let home = std::env::var("HOME").unwrap();
165        let input = format!("{home}/projects/foo");
166        assert_eq!(shorten_path(&input), "~/projects/foo");
167    }
168
169    #[test]
170    fn test_shorten_path_no_home_prefix() {
171        assert_eq!(shorten_path("/tmp/foo"), "/tmp/foo");
172    }
173
174    #[test]
175    fn test_shorten_path_buf_works() {
176        let home = std::env::var("HOME").unwrap();
177        let p = std::path::PathBuf::from(format!("{home}/.retro/retro.db"));
178        assert_eq!(shorten_path_buf(&p), "~/.retro/retro.db");
179    }
180}