Skip to main content

thoth_cli/
cli.rs

1use crate::{get_save_file_path, EditorClipboard, ThemeMode, ThothConfig};
2use anyhow::{bail, Result};
3use std::{fs::File, io::Write};
4
5use std::env;
6use std::path::Path;
7use std::process::Command;
8use tempfile::NamedTempFile;
9
10use clap::{Parser, Subcommand};
11
12use crate::utils::{parse_notes, write_notes, ParsedNotes};
13#[derive(Parser)]
14#[command(author = env!("CARGO_PKG_AUTHORS"), version = env!("CARGO_PKG_VERSION"), about, long_about = None, rename_all = "snake_case")]
15pub struct Cli {
16    #[command(subcommand)]
17    pub command: Option<Commands>,
18}
19
20#[derive(Subcommand)]
21#[command(rename_all = "snake_case")]
22pub enum Commands {
23    /// Add a new block to the scratchpad.
24    /// If a block with the same name already exists, the content is appended to it.
25    Add {
26        /// Name of the block to be added
27        name: String,
28        /// Contents to be associated with the named block
29        content: Option<String>,
30    },
31    /// List all of the blocks within your thoth scratchpad
32    List {
33        /// Show additional details such as line counts per block
34        #[arg(short = 'l', long)]
35        long: bool,
36    },
37    /// Load backup file as the main thoth markdown file
38    LoadBackup,
39    /// Read the contents of the clipboard backup file
40    ReadClipboard,
41    /// Delete a block by name
42    Delete {
43        /// The name of the block to be deleted
44        name: String,
45    },
46    /// View (STDOUT) the contents of the block by name
47    View {
48        /// The name of the block to be used
49        name: String,
50    },
51    /// Copy the contents of a block to the system clipboard
52    Copy {
53        /// The name of the block to be used
54        name: String,
55    },
56    /// Rename a block
57    Rename {
58        /// Current name of the block
59        old_name: String,
60        /// New name for the block
61        new_name: String,
62    },
63    /// Edit the contents of a block with your $EDITOR/$VISUAL editor
64    Edit {
65        /// The name of the block to be edited
66        name: String,
67    },
68    /// Set the theme to light or dark mode
69    Theme {
70        /// The theme to set: 'light' or 'dark'
71        mode: String,
72    },
73    /// Get the current theme
74    GetTheme,
75}
76
77pub fn set_theme(mode: &str) -> Result<()> {
78    let mode_lowercase = mode.to_lowercase();
79
80    let mut config = ThothConfig::load()?;
81
82    match mode_lowercase.as_str() {
83        "light" => {
84            config.set_theme(ThemeMode::Light)?;
85            println!("Theme set to light mode");
86        }
87        "dark" => {
88            config.set_theme(ThemeMode::Dark)?;
89            println!("Theme set to dark mode");
90        }
91        _ => {
92            bail!("Invalid theme mode. Use 'light' or 'dark'");
93        }
94    }
95
96    Ok(())
97}
98
99pub fn get_theme() -> Result<()> {
100    let config = ThothConfig::load()?;
101
102    match config.theme {
103        ThemeMode::Light => println!("Current theme: light"),
104        ThemeMode::Dark => println!("Current theme: dark"),
105    }
106
107    Ok(())
108}
109
110pub fn read_clipboard_backup() -> Result<()> {
111    let file_path = crate::get_clipboard_backup_file_path();
112    if !file_path.exists() {
113        println!("No clipboard backup file found at {}", file_path.display());
114        return Ok(());
115    }
116
117    let content = std::fs::read_to_string(&file_path)?;
118    if content.is_empty() {
119        println!("Clipboard backup file exists but is empty.");
120    } else {
121        println!("{}", content);
122    }
123    Ok(())
124}
125
126/// Load the notes file as parsed blocks. Returns an empty result when the file
127/// does not exist yet.
128fn load_parsed_notes(file_path: &Path) -> Result<ParsedNotes> {
129    if !file_path.exists() {
130        return Ok(ParsedNotes::default());
131    }
132    let content = std::fs::read_to_string(file_path)?;
133    Ok(parse_notes(&content))
134}
135
136/// Write parsed blocks to the notes file, creating parent directories as needed.
137fn save_parsed_notes(file_path: &Path, parsed: &ParsedNotes) -> Result<()> {
138    if let Some(parent) = file_path.parent() {
139        if !parent.exists() {
140            std::fs::create_dir_all(parent)?;
141        }
142    }
143    let mut file = File::create(file_path)?;
144    write_notes(&mut file, parsed)
145}
146
147fn find_block_index(parsed: &ParsedNotes, name: &str) -> Option<usize> {
148    parsed.blocks.iter().position(|(title, _)| title == name)
149}
150
151pub fn add_block(name: &str, content: &str) -> Result<()> {
152    let file_path = get_save_file_path();
153    let mut parsed = load_parsed_notes(&file_path)?;
154    let content_lines: Vec<String> = content.lines().map(str::to_string).collect();
155
156    if let Some(index) = find_block_index(&parsed, name) {
157        let lines = &mut parsed.blocks[index].1;
158        // Separate appended content from existing content with a blank line
159        if let Some(last) = lines.last() {
160            if !last.is_empty() {
161                lines.push(String::new());
162            }
163        }
164        lines.extend(content_lines);
165        save_parsed_notes(&file_path, &parsed)?;
166        println!("Appended content to existing block '{}'.", name);
167    } else {
168        parsed.blocks.push((name.to_string(), content_lines));
169        save_parsed_notes(&file_path, &parsed)?;
170        println!("Block '{}' added successfully.", name);
171    }
172    Ok(())
173}
174
175pub fn list_blocks() -> Result<()> {
176    list_blocks_with_options(false)
177}
178
179pub fn list_blocks_with_options(long: bool) -> Result<()> {
180    let file_path = get_save_file_path();
181    if !file_path.exists() {
182        println!(
183            "No notes file found at {}. Add one with `thoth add <name> <content>` or start the TUI with `thoth`.",
184            file_path.display()
185        );
186        return Ok(());
187    }
188
189    let parsed = load_parsed_notes(&file_path)?;
190    for (title, lines) in &parsed.blocks {
191        if long {
192            println!("{} ({} lines)", title, lines.len());
193        } else {
194            println!("{}", title);
195        }
196    }
197
198    Ok(())
199}
200
201pub fn replace_from_backup() -> Result<()> {
202    let (backup_textareas, backup_textareas_titles) =
203        crate::load_textareas(crate::get_save_backup_file_path())?;
204    crate::save_textareas(
205        &backup_textareas,
206        &backup_textareas_titles,
207        get_save_file_path(),
208    )
209}
210
211pub fn view_block(name: &str) -> Result<()> {
212    let parsed = load_parsed_notes(&get_save_file_path())?;
213
214    match find_block_index(&parsed, name) {
215        Some(index) => {
216            for line in &parsed.blocks[index].1 {
217                println!("{}", line);
218            }
219        }
220        None => bail!(
221            "Block '{}' not found. Use `thoth list` to see available blocks.",
222            name
223        ),
224    }
225    Ok(())
226}
227
228pub fn copy_block(name: &str) -> Result<()> {
229    let parsed = load_parsed_notes(&get_save_file_path())?;
230
231    match find_block_index(&parsed, name) {
232        Some(index) => {
233            let content = parsed.blocks[index].1.join("\n");
234            let mut ctx = EditorClipboard::new()
235                .map_err(|e| anyhow::anyhow!("Failed to create clipboard context: {}", e))?;
236            ctx.set_contents(content).map_err(|e| {
237                anyhow::anyhow!(
238                    "Failed to copy contents of block {} to system clipboard: {}",
239                    name,
240                    e
241                )
242            })?;
243            println!("Successfully copied contents from block {}", name);
244        }
245        None => bail!(
246            "Didn't find the block. Please try again. You can use `thoth list` to find the name of all blocks"
247        ),
248    }
249    Ok(())
250}
251
252pub fn delete_block(name: &str) -> Result<()> {
253    let file_path = get_save_file_path();
254    let parsed = load_parsed_notes(&file_path)?;
255
256    match find_block_index(&parsed, name) {
257        Some(index) => {
258            let mut remaining = parsed;
259            remaining.blocks.remove(index);
260            save_parsed_notes(&file_path, &remaining)?;
261            println!("Block '{}' deleted successfully.", name);
262        }
263        None => bail!("Block '{}' not found.", name),
264    }
265    Ok(())
266}
267
268pub fn rename_block(old_name: &str, new_name: &str) -> Result<()> {
269    let file_path = get_save_file_path();
270    let mut parsed = load_parsed_notes(&file_path)?;
271    // Renaming to an existing name would create a duplicate block
272    let new_index = find_block_index(&parsed, new_name);
273    if let Some(index) = new_index {
274        if parsed.blocks[index].0 == new_name && Some(index) != find_block_index(&parsed, old_name)
275        {
276            bail!(
277                "A block named '{}' already exists. Choose a different name.",
278                new_name
279            );
280        }
281    }
282
283    match find_block_index(&parsed, old_name) {
284        Some(index) => {
285            parsed.blocks[index].0 = new_name.to_string();
286            save_parsed_notes(&file_path, &parsed)?;
287            println!("Block '{}' renamed to '{}'.", old_name, new_name);
288        }
289        None => bail!(
290            "Block '{}' not found. Use `thoth list` to see available blocks.",
291            old_name
292        ),
293    }
294    Ok(())
295}
296
297pub fn edit_block(name: &str) -> Result<()> {
298    let file_path = get_save_file_path();
299    let mut parsed = load_parsed_notes(&file_path)?;
300    let index = find_block_index(&parsed, name).ok_or_else(|| {
301        anyhow::anyhow!(
302            "Block '{}' not found. Use `thoth list` to see available blocks.",
303            name
304        )
305    })?;
306
307    let content = parsed.blocks[index].1.join("\n");
308    let mut temp_file = NamedTempFile::new()?;
309    temp_file.write_all(content.as_bytes())?;
310    temp_file.flush()?;
311
312    let editor = env::var("VISUAL")
313        .or_else(|_| env::var("EDITOR"))
314        .unwrap_or_else(|_| "vi".to_string());
315
316    let status = Command::new(&editor).arg(temp_file.path()).status()?;
317    if !status.success() {
318        bail!(format!("Editor '{}' returned non-zero status", editor));
319    }
320
321    let edited_content = std::fs::read_to_string(temp_file.path())?;
322    parsed.blocks[index].1 = edited_content.lines().map(str::to_string).collect();
323    save_parsed_notes(&file_path, &parsed)?;
324    println!("Block '{}' updated successfully.", name);
325    Ok(())
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use std::path::PathBuf;
332    use std::sync::Mutex;
333
334    // The notes location is process-global, so serialize tests that touch it.
335    static ENV_LOCK: Mutex<()> = Mutex::new(());
336
337    fn with_notes_dir<F: FnOnce()>(f: F) {
338        let _guard = ENV_LOCK.lock().unwrap();
339        let dir = tempfile::TempDir::new().unwrap();
340        std::env::set_var("THOTH_NOTES_DIR", dir.path());
341        f();
342        std::env::remove_var("THOTH_NOTES_DIR");
343    }
344
345    fn notes_file() -> PathBuf {
346        get_save_file_path()
347    }
348
349    fn file_content() -> String {
350        std::fs::read_to_string(notes_file()).unwrap()
351    }
352
353    #[test]
354    fn test_add_creates_new_block() {
355        with_notes_dir(|| {
356            add_block("groceries", "bananas\nmilk").unwrap();
357            assert_eq!(file_content(), "# groceries\nbananas\nmilk\n");
358        });
359    }
360
361    #[test]
362    fn test_add_existing_block_appends() {
363        with_notes_dir(|| {
364            add_block("groceries", "bananas").unwrap();
365            add_block("groceries", "milk").unwrap();
366            assert_eq!(file_content(), "# groceries\nbananas\n\nmilk\n");
367        });
368    }
369
370    #[test]
371    fn test_add_preserves_code_fences() {
372        with_notes_dir(|| {
373            add_block("code", "```rust\n# comment\n```\nheading after code").unwrap();
374            let content = file_content();
375            // Content inside a fenced block is kept verbatim (unescaped)
376            assert_eq!(
377                content,
378                "# code\n```rust\n# comment\n```\nheading after code\n"
379            );
380        });
381    }
382
383    #[test]
384    fn test_view_missing_block_errors() {
385        with_notes_dir(|| {
386            add_block("real", "content").unwrap();
387            assert!(view_block("missing").is_err());
388        });
389    }
390
391    #[test]
392    fn test_delete_missing_block_preserves_file() {
393        with_notes_dir(|| {
394            add_block("keeper", "content").unwrap();
395            let before = file_content();
396            assert!(delete_block("nope").is_err());
397            assert_eq!(file_content(), before);
398        });
399    }
400
401    #[test]
402    fn test_delete_existing_block() {
403        with_notes_dir(|| {
404            add_block("keeper", "content").unwrap();
405            add_block("doomed", "bye").unwrap();
406            delete_block("doomed").unwrap();
407            let content = file_content();
408            assert!(content.contains("# keeper"));
409            assert!(!content.contains("doomed"));
410        });
411    }
412
413    #[test]
414    fn test_rename_block() {
415        with_notes_dir(|| {
416            add_block("old", "content").unwrap();
417            rename_block("old", "new").unwrap();
418            let content = file_content();
419            assert!(content.contains("# new"));
420            assert!(!content.contains("# old"));
421
422            // Renaming to an existing name is rejected
423            add_block("other", "x").unwrap();
424            assert!(rename_block("other", "new").is_err());
425        });
426    }
427
428    #[test]
429    fn test_preamble_preserved_on_rewrite() {
430        with_notes_dir(|| {
431            let dir = notes_file();
432            std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
433            std::fs::write(&dir, "loose notes\n# block\ncontent\n").unwrap();
434            delete_block("block").unwrap();
435            assert_eq!(file_content(), "loose notes\n");
436        });
437    }
438}