1use crate::Result;
2use crate::constants::*;
3use linkify::{LinkFinder, LinkKind};
4use std::io::{self, Write};
5use std::process::{Command, Stdio};
6use uuid::Uuid;
7
8pub fn exit_fail(msg: &str) -> ! {
10 eprintln!("\x1b[31m{}\x1b[0m", msg);
11 std::process::exit(1);
12}
13
14pub fn confirm_or_abort(msg: &str) -> Result<()> {
16 eprint!("{} [y/n] ", msg);
17 io::stderr().flush()?;
18
19 let mut input = String::new();
20 io::stdin().read_line(&mut input)?;
21
22 let normalized = input.trim().to_lowercase();
23 if normalized == "y" || normalized == "yes" {
24 Ok(())
25 } else {
26 exit_fail("Aborted.");
27 }
28}
29
30pub fn must_get_uuid4_string() -> String {
32 Uuid::new_v4().to_string()
33}
34
35pub fn is_valid_uuid4_string(s: &str) -> bool {
37 Uuid::parse_str(s).is_ok()
38}
39
40pub fn run_cmd(name: &str, args: &[&str]) -> Result<()> {
42 let status = Command::new(name)
43 .args(args)
44 .stdin(Stdio::inherit())
45 .stdout(Stdio::inherit())
46 .stderr(Stdio::inherit())
47 .status()?;
48
49 if !status.success() {
50 return Err(crate::RstaskError::Other(format!(
51 "Command {} failed with status: {}",
52 name, status
53 )));
54 }
55
56 Ok(())
57}
58
59pub fn make_temp_filename(id: i32, summary: &str, ext: &str) -> String {
61 let mut truncated = String::new();
62 let mut prev_was_hyphen = true; for c in summary.chars().take(21) {
65 if c.len_utf8() != 1 {
67 continue;
68 }
69
70 if c.is_ascii_punctuation() {
72 continue;
73 }
74
75 if !c.is_alphanumeric() {
77 if !prev_was_hyphen {
78 truncated.push('-');
79 prev_was_hyphen = true;
80 }
81 continue;
82 }
83
84 truncated.push(c);
85 prev_was_hyphen = false;
86 }
87
88 let lowered = truncated.to_lowercase();
89 format!("rstask.*.{}-{}.{}", id, lowered, ext)
90}
91
92pub fn must_edit_bytes(data: &[u8], tmp_filename: &str) -> Result<Vec<u8>> {
94 let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
95 let editor_parts: Vec<&str> = editor.split_whitespace().collect();
96
97 if editor_parts.is_empty() {
98 return Err(crate::RstaskError::Other("EDITOR is empty".to_string()));
99 }
100
101 let mut tmpfile = tempfile::Builder::new()
102 .prefix("")
103 .suffix(tmp_filename)
104 .tempfile()?;
105
106 tmpfile.write_all(data)?;
107 tmpfile.flush()?;
108
109 let path = tmpfile.path().to_path_buf();
110
111 let mut cmd = Command::new(editor_parts[0]);
112 if editor_parts.len() > 1 {
113 cmd.args(&editor_parts[1..]);
114 }
115 cmd.arg(&path);
116
117 let status = cmd
118 .stdin(Stdio::inherit())
119 .stdout(Stdio::inherit())
120 .stderr(Stdio::inherit())
121 .status()?;
122
123 if !status.success() {
124 return Err(crate::RstaskError::Other(
125 "Failed to run $EDITOR".to_string(),
126 ));
127 }
128
129 let edited = std::fs::read(&path)?;
130 Ok(edited)
131}
132
133pub fn edit_string(content: &str) -> Result<String> {
135 let bytes = must_edit_bytes(content.as_bytes(), "rstask-edit.md")?;
136 Ok(String::from_utf8_lossy(&bytes).to_string())
137}
138
139pub fn slice_contains<T: PartialEq>(haystack: &[T], needle: &T) -> bool {
141 haystack.contains(needle)
142}
143
144pub fn slice_contains_all(subset: &[String], superset: &[String]) -> bool {
146 subset.iter().all(|item| superset.contains(item))
147}
148
149pub fn deduplicate_strings(strings: &mut Vec<String>) {
151 let mut seen = std::collections::HashSet::new();
152 strings.retain(|s| seen.insert(s.clone()));
153}
154
155pub fn extract_urls(text: &str) -> Vec<String> {
157 let mut finder = LinkFinder::new();
158 finder.kinds(&[LinkKind::Url]);
159 finder
160 .links(text)
161 .map(|link| link.as_str().to_string())
162 .collect()
163}
164
165pub fn open_browser(url: &str) -> Result<()> {
167 #[cfg(target_os = "linux")]
168 let cmd = "xdg-open";
169
170 #[cfg(target_os = "windows")]
171 let cmd = "cmd";
172
173 #[cfg(target_os = "macos")]
174 let cmd = "open";
175
176 #[cfg(target_os = "windows")]
177 let args = ["/c", "start", "", url];
178
179 #[cfg(not(target_os = "windows"))]
180 let args = [url];
181
182 Command::new(cmd)
183 .args(args)
184 .stdin(Stdio::null())
185 .stdout(Stdio::null())
186 .stderr(Stdio::null())
187 .spawn()
188 .map_err(|_| crate::RstaskError::Other("Failed to open browser".to_string()))?;
189
190 Ok(())
191}
192
193pub fn get_term_size() -> (usize, usize) {
195 terminal_size::terminal_size()
196 .map(|(w, h)| (w.0 as usize, h.0 as usize))
197 .unwrap_or((80, 24))
198}
199
200pub fn stdout_is_tty() -> bool {
202 *FAKE_PTY || termion::is_tty(&std::io::stdout())
203}
204
205pub fn get_repo_path(repo: &std::path::Path, status: &str) -> std::path::PathBuf {
207 repo.join(status)
208}
209
210pub fn must_get_repo_path(
212 repo: &std::path::Path,
213 status: &str,
214 filename: &str,
215) -> std::path::PathBuf {
216 repo.join(status).join(filename)
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn test_is_valid_uuid4_string() {
225 assert!(is_valid_uuid4_string(
226 "550e8400-e29b-41d4-a716-446655440000"
227 ));
228 assert!(!is_valid_uuid4_string("invalid-uuid"));
229 assert!(!is_valid_uuid4_string(""));
230 }
231
232 #[test]
233 fn test_make_temp_filename() {
234 assert_eq!(make_temp_filename(1, "& &", "md"), "rstask.*.1-.md");
235 assert_eq!(
236 make_temp_filename(99, "A simple summary!", "md"),
237 "rstask.*.99-a-simple-summary.md"
238 );
239 assert_eq!(
240 make_temp_filename(1, "& that's that.", "md"),
241 "rstask.*.1-thats-that.md"
242 );
243 assert_eq!(
244 make_temp_filename(2147483647, "J's $100, != €100", "md"),
245 "rstask.*.2147483647-js-100-100.md"
246 );
247 }
248
249 #[test]
250 fn test_slice_contains_all() {
251 assert!(slice_contains_all(&[], &[]));
252 assert!(slice_contains_all(
253 &["one".to_string()],
254 &["one".to_string()]
255 ));
256 assert!(!slice_contains_all(
257 &["one".to_string()],
258 &["two".to_string()]
259 ));
260 assert!(!slice_contains_all(&["one".to_string()], &[]));
261 assert!(slice_contains_all(
262 &["one".to_string()],
263 &["one".to_string(), "two".to_string()]
264 ));
265 assert!(slice_contains_all(
266 &["two".to_string(), "one".to_string()],
267 &["three".to_string(), "one".to_string(), "two".to_string()]
268 ));
269 assert!(!slice_contains_all(
270 &["apple".to_string(), "two".to_string(), "one".to_string()],
271 &["three".to_string(), "one".to_string(), "two".to_string()]
272 ));
273 assert!(slice_contains_all(
274 &[],
275 &["three".to_string(), "one".to_string(), "two".to_string()]
276 ));
277 }
278
279 #[test]
280 fn test_deduplicate_strings() {
281 let mut vec = vec![
282 "a".to_string(),
283 "b".to_string(),
284 "a".to_string(),
285 "c".to_string(),
286 ];
287 deduplicate_strings(&mut vec);
288 assert_eq!(vec, vec!["a".to_string(), "b".to_string(), "c".to_string()]);
289 }
290
291 #[test]
292 fn test_extract_urls() {
293 let text = "Check out https://example.com and http://test.org for more info";
294 let urls = extract_urls(text);
295 assert_eq!(urls.len(), 2);
296 assert!(urls.contains(&"https://example.com".to_string()));
297 assert!(urls.contains(&"http://test.org".to_string()));
298 }
299}