pocket_cli/utils/
mod.rs

1use anyhow::{Result, anyhow, Context};
2use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
3use owo_colors::OwoColorize;
4use std::fs;
5use std::io::{self, Read, Write};
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8use std::env;
9use std::time::SystemTime;
10
11use crate::models::ContentType;
12use tempfile::NamedTempFile;
13
14// Add clipboard module
15pub mod clipboard;
16
17// Add summarization module
18pub mod summarization;
19
20// Re-export clipboard functions for convenience
21pub use clipboard::{read_clipboard, write_clipboard};
22
23// Re-export summarization functions for convenience
24pub use summarization::{summarize_text, SummaryMetadata};
25
26/// Read content from a file
27pub fn read_file_content(path: &Path) -> Result<String> {
28    fs::read_to_string(path).map_err(|e| anyhow!("Failed to read file {}: {}", path.display(), e))
29}
30
31/// Read content from stdin
32pub fn read_stdin_content() -> Result<String> {
33    let mut buffer = String::new();
34    io::stdin().read_to_string(&mut buffer)?;
35    Ok(buffer)
36}
37
38/// Open the system editor and return the content
39pub fn open_editor(initial_content: Option<&str>) -> Result<String> {
40    // Find the user's preferred editor
41    let editor = get_editor()?;
42    
43    // Create a temporary file
44    let mut temp_file = NamedTempFile::new()?;
45    
46    // Write initial content if provided
47    if let Some(content) = initial_content {
48        temp_file.write_all(content.as_bytes())?;
49        temp_file.flush()?;
50    }
51    
52    // Get the path to the temporary file
53    let temp_path = temp_file.path().to_path_buf();
54    
55    // Open the editor
56    let status = Command::new(&editor)
57        .arg(&temp_path)
58        .stdin(Stdio::inherit())
59        .stdout(Stdio::inherit())
60        .stderr(Stdio::inherit())
61        .status()
62        .with_context(|| format!("Failed to open editor: {}", editor))?;
63    
64    if !status.success() {
65        return Err(anyhow!("Editor exited with non-zero status: {}", status));
66    }
67    
68    // Read the content from the temporary file
69    let content = fs::read_to_string(&temp_path)
70        .with_context(|| format!("Failed to read from temporary file: {}", temp_path.display()))?;
71    
72    Ok(content)
73}
74
75/// Open the system editor with syntax highlighting hints based on content type
76pub fn open_editor_with_type(content_type: ContentType, initial_content: Option<&str>) -> Result<String> {
77    // Find the user's preferred editor
78    let editor = get_editor()?;
79    
80    // Create a temporary file with appropriate extension
81    let extension = match content_type {
82        ContentType::Code => ".rs", // Default to Rust, but could be more specific
83        ContentType::Text => ".txt",
84        ContentType::Script => ".sh",
85        ContentType::Other(ref lang) => {
86            match lang.as_str() {
87                "javascript" | "js" => ".js",
88                "typescript" | "ts" => ".ts",
89                "python" | "py" => ".py",
90                "ruby" | "rb" => ".rb",
91                "html" => ".html",
92                "css" => ".css",
93                "json" => ".json",
94                "yaml" | "yml" => ".yml",
95                "markdown" | "md" => ".md",
96                "shell" | "sh" | "bash" => ".sh",
97                "sql" => ".sql",
98                _ => ".txt"
99            }
100        }
101    };
102    
103    // Create a temporary file with appropriate extension
104    let temp_dir = tempfile::tempdir()?;
105    let timestamp = SystemTime::now()
106        .duration_since(SystemTime::UNIX_EPOCH)
107        .unwrap_or_default()
108        .as_secs();
109    let file_name = format!("pocket_temp_{}{}", timestamp, extension);
110    let temp_path = temp_dir.path().join(file_name);
111    
112    // Write initial content if provided
113    if let Some(content) = initial_content {
114        fs::write(&temp_path, content)?;
115    } else {
116        // Add template based on content type if no initial content
117        let template = match content_type {
118            ContentType::Code => match extension {
119                ".rs" => "// Rust code snippet\n\nfn example() {\n    // Your code here\n}\n",
120                ".js" => "// JavaScript code snippet\n\nfunction example() {\n    // Your code here\n}\n",
121                ".ts" => "// TypeScript code snippet\n\nfunction example(): void {\n    // Your code here\n}\n",
122                ".py" => "# Python code snippet\n\ndef example():\n    # Your code here\n    pass\n",
123                ".rb" => "# Ruby code snippet\n\ndef example\n  # Your code here\nend\n",
124                ".html" => "<!DOCTYPE html>\n<html>\n<head>\n    <title>Title</title>\n</head>\n<body>\n    <!-- Your content here -->\n</body>\n</html>\n",
125                ".css" => "/* CSS snippet */\n\n.example {\n    /* Your styles here */\n}\n",
126                ".json" => "{\n    \"key\": \"value\"\n}\n",
127                ".yml" => "# YAML snippet\nkey: value\nnested:\n  subkey: subvalue\n",
128                ".sh" => "#!/bin/bash\n\n# Your script here\necho \"Hello, world!\"\n",
129                ".sql" => "-- SQL snippet\nSELECT * FROM table WHERE condition;\n",
130                _ => "// Code snippet\n\n// Your code here\n"
131            },
132            ContentType::Text => "# Title\n\nYour text here...\n",
133            ContentType::Script => "#!/bin/bash\n\n# Your script here\necho \"Hello, world!\"\n",
134            ContentType::Other(_) => "# Content\n\nYour content here...\n"
135        };
136        fs::write(&temp_path, template)?;
137    }
138    
139    // Open the editor
140    let status = Command::new(&editor)
141        .arg(&temp_path)
142        .stdin(Stdio::inherit())
143        .stdout(Stdio::inherit())
144        .stderr(Stdio::inherit())
145        .status()
146        .with_context(|| format!("Failed to open editor: {}", editor))?;
147    
148    if !status.success() {
149        return Err(anyhow!("Editor exited with non-zero status: {}", status));
150    }
151    
152    // Read the content from the temporary file
153    let content = fs::read_to_string(&temp_path)
154        .with_context(|| format!("Failed to read from temporary file: {}", temp_path.display()))?;
155    
156    Ok(content)
157}
158
159/// Edit an existing entry
160pub fn edit_entry(id: &str, content: &str, content_type: ContentType) -> Result<String> {
161    println!("Opening entry {} in editor. Make your changes and save to update.", id.cyan());
162    open_editor_with_type(content_type, Some(content))
163}
164
165/// Get the user's preferred editor
166fn get_editor() -> Result<String> {
167    // Try to load from Pocket config first
168    if let Ok(storage) = crate::storage::StorageManager::new() {
169        if let Ok(config) = storage.load_config() {
170            if !config.user.editor.is_empty() {
171                return Ok(config.user.editor);
172            }
173        }
174    }
175    
176    // Then try environment variables
177    if let Ok(editor) = env::var("EDITOR") {
178        if !editor.is_empty() {
179            return Ok(editor);
180        }
181    }
182    
183    if let Ok(editor) = env::var("VISUAL") {
184        if !editor.is_empty() {
185            return Ok(editor);
186        }
187    }
188    
189    // Ask the user for their preferred editor
190    println!("{}", "No preferred editor found in config or environment variables.".yellow());
191    let editor = input::<String>("Please enter your preferred editor (e.g., vim, nano, code):", None)?;
192    
193    // Save the preference to config
194    if let Ok(storage) = crate::storage::StorageManager::new() {
195        if let Ok(mut config) = storage.load_config() {
196            config.user.editor = editor.clone();
197            let _ = storage.save_config(&config); // Ignore errors when saving config
198        }
199    }
200    
201    Ok(editor)
202}
203
204/// Detect content type from extension or content
205pub fn detect_content_type(path: Option<&Path>, content: Option<&str>) -> ContentType {
206    // Check file extension first if path is provided
207    if let Some(path) = path {
208        if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
209            match extension.to_lowercase().as_str() {
210                "rs" => return ContentType::Code,
211                "go" => return ContentType::Code,
212                "js" | "ts" => return ContentType::Code,
213                "py" => return ContentType::Code,
214                "java" => return ContentType::Code,
215                "c" | "cpp" | "h" | "hpp" => return ContentType::Code,
216                "cs" => return ContentType::Code,
217                "rb" => return ContentType::Code,
218                "php" => return ContentType::Code,
219                "html" | "htm" => return ContentType::Other("html".to_string()),
220                "css" => return ContentType::Other("css".to_string()),
221                "json" => return ContentType::Other("json".to_string()),
222                "yaml" | "yml" => return ContentType::Other("yaml".to_string()),
223                "md" | "markdown" => return ContentType::Other("markdown".to_string()),
224                "sql" => return ContentType::Other("sql".to_string()),
225                "sh" | "bash" | "zsh" => return ContentType::Script,
226                _ => {}
227            }
228        }
229        
230        // Check filename for specific patterns
231        if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
232            if filename.starts_with("Dockerfile") {
233                return ContentType::Other("dockerfile".to_string());
234            }
235            
236            if filename == "Makefile" || filename == "makefile" {
237                return ContentType::Other("makefile".to_string());
238            }
239        }
240    }
241    
242    // Check content if provided
243    if let Some(content) = content {
244        // Check for shebang line
245        if content.starts_with("#!/bin/sh") || 
246           content.starts_with("#!/bin/bash") || 
247           content.starts_with("#!/usr/bin/env bash") ||
248           content.starts_with("#!/bin/zsh") || 
249           content.starts_with("#!/usr/bin/env zsh") {
250            return ContentType::Script;
251        }
252        
253        // Check for common code patterns
254        let trimmed = content.trim();
255        if trimmed.starts_with("#include") || trimmed.starts_with("#define") || 
256           trimmed.starts_with("import ") || trimmed.starts_with("from ") || 
257           trimmed.starts_with("package ") || trimmed.starts_with("using ") ||
258           trimmed.starts_with("function ") || trimmed.starts_with("def ") ||
259           trimmed.starts_with("class ") || trimmed.starts_with("struct ") ||
260           trimmed.starts_with("enum ") || trimmed.starts_with("interface ") ||
261           trimmed.contains("public class ") || trimmed.contains("private class ") ||
262           trimmed.contains("fn ") || trimmed.contains("pub fn ") ||
263           trimmed.contains("impl ") || trimmed.contains("trait ") {
264            return ContentType::Code;
265        }
266        
267        // Check for JSON
268        if (trimmed.starts_with('{') && trimmed.ends_with('}')) ||
269           (trimmed.starts_with('[') && trimmed.ends_with(']')) {
270            return ContentType::Other("json".to_string());
271        }
272        
273        // Check for HTML
274        if trimmed.starts_with("<!DOCTYPE html>") || 
275           trimmed.starts_with("<html>") || 
276           trimmed.contains("<body>") {
277            return ContentType::Other("html".to_string());
278        }
279        
280        // Check for Markdown
281        if trimmed.starts_with("# ") || 
282           trimmed.contains("\n## ") || 
283           trimmed.contains("\n### ") {
284            return ContentType::Other("markdown".to_string());
285        }
286    }
287    
288    // Default to text
289    ContentType::Text
290}
291
292/// Prompt the user for confirmation
293pub fn confirm(message: &str, default: bool) -> Result<bool> {
294    Ok(Confirm::with_theme(&ColorfulTheme::default())
295        .with_prompt(message)
296        .default(default)
297        .interact()?)
298}
299
300/// Prompt the user for input
301pub fn input<T>(message: &str, default: Option<T>) -> Result<T>
302where
303    T: std::str::FromStr + std::fmt::Display + Clone,
304    T::Err: std::fmt::Display,
305{
306    let theme = ColorfulTheme::default();
307    
308    if let Some(default_value) = default {
309        Ok(Input::<T>::with_theme(&theme)
310            .with_prompt(message)
311            .default(default_value)
312            .interact()?)
313    } else {
314        Ok(Input::<T>::with_theme(&theme)
315            .with_prompt(message)
316            .interact()?)
317    }
318}
319
320/// Prompt the user to select from a list of options
321pub fn select<T>(message: &str, options: &[T]) -> Result<usize>
322where
323    T: std::fmt::Display,
324{
325    Ok(Select::with_theme(&ColorfulTheme::default())
326        .with_prompt(message)
327        .items(options)
328        .default(0)
329        .interact()?)
330}
331
332/// Format content with tag
333pub fn format_with_tag(tag: &str, content: &str) -> String {
334    format!("--- {} ---\n{}\n--- end {} ---\n", tag, content, tag)
335}
336
337/// Truncate a string to a maximum length with ellipsis
338pub fn truncate_string(s: &str, max_len: usize) -> String {
339    if s.len() <= max_len {
340        s.to_string()
341    } else {
342        let mut result = s.chars().take(max_len - 3).collect::<String>();
343        result.push_str("...");
344        result
345    }
346}
347
348/// Extract the first line of a string
349pub fn first_line(s: &str) -> &str {
350    s.lines().next().unwrap_or(s)
351}
352
353/// Get a title from content (first line or truncated content)
354pub fn get_title_from_content(content: &str) -> String {
355    let first = first_line(content);
356    if first.is_empty() {
357        truncate_string(content, 50)
358    } else {
359        truncate_string(first, 50)
360    }
361}
362
363/// Expand a path string with tilde and environment variables
364pub fn expand_path(path: &str) -> Result<PathBuf> {
365    let expanded = if path.starts_with("~") {
366        if let Some(home) = dirs::home_dir() {
367            let path_without_tilde = path.strip_prefix("~").unwrap_or("");
368            home.join(path_without_tilde.strip_prefix("/").unwrap_or(path_without_tilde))
369        } else {
370            return Err(anyhow!("Could not determine home directory"));
371        }
372    } else {
373        PathBuf::from(path)
374    };
375
376    // Expand environment variables
377    let mut result = String::new();
378    let mut in_var = false;
379    let mut var_name = String::new();
380
381    for c in expanded.to_str().unwrap_or(path).chars() {
382        if in_var {
383            if c.is_alphanumeric() || c == '_' {
384                var_name.push(c);
385            } else {
386                if !var_name.is_empty() {
387                    if let Ok(value) = std::env::var(&var_name) {
388                        result.push_str(&value);
389                    }
390                    var_name.clear();
391                } else {
392                    result.push('$');
393                }
394                result.push(c);
395                in_var = false;
396            }
397        } else if c == '$' {
398            in_var = true;
399        } else {
400            result.push(c);
401        }
402    }
403
404    if in_var && !var_name.is_empty() {
405        if let Ok(value) = std::env::var(&var_name) {
406            result.push_str(&value);
407        }
408    }
409
410    Ok(PathBuf::from(result))
411}
412
413/// Find the cursor position in a file
414/// This looks for a special marker like "// CURSOR" and returns its position
415pub fn get_cursor_position(content: &str) -> Option<usize> {
416    // First, look for a dedicated cursor marker
417    for marker in ["// CURSOR", "# CURSOR", "<!-- CURSOR -->", "/* CURSOR */"] {
418        if let Some(pos) = content.find(marker) {
419            return Some(pos);
420        }
421    }
422    
423    // If no marker found, try to find a reasonable position
424    // Look for two consecutive empty lines
425    if let Some(pos) = content.find("\n\n\n") {
426        return Some(pos + 2); // Position after the second newline
427    }
428    
429    // Look for the end of the file
430    Some(content.len())
431}