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