Skip to main content

rstask_core/
util.rs

1use crate::Result;
2use crate::constants::*;
3use linkify::{LinkFinder, LinkKind};
4use std::io::{self, Write};
5use std::process::{Command, Stdio};
6use uuid::Uuid;
7
8/// Prints an error message in red and exits
9pub fn exit_fail(msg: &str) -> ! {
10    eprintln!("\x1b[31m{}\x1b[0m", msg);
11    std::process::exit(1);
12}
13
14/// Asks for user confirmation or exits
15pub 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
30/// Generates a new UUID v4 string
31pub fn must_get_uuid4_string() -> String {
32    Uuid::new_v4().to_string()
33}
34
35/// Validates a UUID v4 string
36pub fn is_valid_uuid4_string(s: &str) -> bool {
37    Uuid::parse_str(s).is_ok()
38}
39
40/// Runs a command with stdin/stdout/stderr inherited
41pub 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
59/// Creates a temporary filename for editing
60pub fn make_temp_filename(id: i32, summary: &str, ext: &str) -> String {
61    let mut truncated = String::new();
62    let mut prev_was_hyphen = true; // Start true to skip leading hyphens
63
64    for c in summary.chars().take(21) {
65        // Skip multi-byte UTF-8 characters
66        if c.len_utf8() != 1 {
67            continue;
68        }
69
70        // Skip punctuation
71        if c.is_ascii_punctuation() {
72            continue;
73        }
74
75        // Convert spaces and other non-alphanumeric to hyphens
76        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
92/// Opens an editor to edit bytes, returns the edited content
93pub 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
133/// Opens an editor to edit a string, returns the edited content
134pub 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
139/// Checks if a slice contains an item
140pub fn slice_contains<T: PartialEq>(haystack: &[T], needle: &T) -> bool {
141    haystack.contains(needle)
142}
143
144/// Checks if subset is contained in superset
145pub fn slice_contains_all(subset: &[String], superset: &[String]) -> bool {
146    subset.iter().all(|item| superset.contains(item))
147}
148
149/// Deduplicates strings in a vector (preserves order)
150pub 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
155/// Extracts URLs from text using linkify (similar to Go's xurls.Relaxed)
156pub 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
165/// Opens a URL in the default browser
166pub 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
193/// Gets terminal size (width, height)
194pub 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
200/// Checks if stdout is a TTY
201pub fn stdout_is_tty() -> bool {
202    *FAKE_PTY || termion::is_tty(&std::io::stdout())
203}
204
205/// Gets the repository path for a given status
206pub fn get_repo_path(repo: &std::path::Path, status: &str) -> std::path::PathBuf {
207    repo.join(status)
208}
209
210/// Gets the repository path or exits on error
211pub 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}