1use crate::errors::CoreError;
2use chrono::Utc;
3use std::fs::OpenOptions;
4use std::io::Write;
5use std::path::Path;
6
7pub 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
34pub 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
46pub 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
57pub fn shorten_path_buf(path: &std::path::Path) -> String {
59 shorten_path(&path.display().to_string())
60}
61
62pub 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
72pub 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 let s = "caf\u{00e9}!";
153 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}