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 {
26 name: String,
28 content: Option<String>,
30 },
31 List {
33 #[arg(short = 'l', long)]
35 long: bool,
36 },
37 LoadBackup,
39 ReadClipboard,
41 Delete {
43 name: String,
45 },
46 View {
48 name: String,
50 },
51 Copy {
53 name: String,
55 },
56 Rename {
58 old_name: String,
60 new_name: String,
62 },
63 Edit {
65 name: String,
67 },
68 Theme {
70 mode: String,
72 },
73 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
126fn 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
136fn 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 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 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 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 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 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}