Skip to main content

bkmr/cli/
process.rs

1// src/cli/process.rs
2
3use regex::Regex;
4use std::io::{self, Write};
5use std::sync::Arc;
6use tracing::{debug, instrument};
7
8use crate::application::services::action_service::ActionService;
9use crate::application::services::bookmark_service::BookmarkService;
10use crate::application::services::interpolation_service::InterpolationService;
11use crate::application::services::template_service::TemplateService;
12use crate::cli::bookmark_commands::format_action_description;
13use crate::cli::display::{show_bookmarks, DisplayBookmark, ALL_FIELDS, DEFAULT_FIELDS};
14use crate::cli::error::{CliError, CliResult};
15use crate::domain::bookmark::Bookmark;
16use crate::domain::services::clipboard::ClipboardService;
17use crate::infrastructure::di::ServiceContainer;
18use crate::util::helper::{confirm, ensure_int_vector};
19
20/// Process a list of bookmarks interactively
21#[instrument(skip_all, level = "debug")]
22pub fn process(
23    bookmarks: &[Bookmark],
24    services: &ServiceContainer,
25    settings: &crate::config::Settings,
26) -> CliResult<()> {
27    if bookmarks.is_empty() {
28        return Ok(());
29    }
30
31    let help_text = r#"
32       <n1> <n2>:      performs default action on selection (open URI, copy snippet, etc.)
33       p <n1> <n2>:    print id-list of selection
34       p:              print all ids
35       d <n1> <n2>:    delete selection
36       e <n1> <n2>:    edit selection
37       t <n1> <n2>:    touch selection (update timestamp)
38       y <n1> <n2>:    yank/copy URL(s) to clipboard
39       q | ENTER:      quit
40       h:              help
41   "#;
42
43    let regex = Regex::new(r"^\d+").unwrap();
44    loop {
45        eprint!("> ");
46        io::stdout().flush().map_err(CliError::Io)?;
47
48        let mut input = String::new();
49        io::stdin().read_line(&mut input).map_err(CliError::Io)?;
50
51        let tokens = parse_input(&input);
52        if tokens.is_empty() {
53            break;
54        }
55
56        match tokens[0].as_str() {
57            "p" => {
58                if tokens.len() == 1 {
59                    // Just "p" command, print all ids
60                    print_all_bookmark_ids(bookmarks)?;
61                } else if let Some(indices) = ensure_int_vector(&tokens[1..]) {
62                    // "p" with indices
63                    print_bookmark_ids(indices, bookmarks)?;
64                } else {
65                    eprintln!("Invalid input, only numbers allowed");
66                    continue;
67                }
68                break;
69            }
70            "d" => {
71                if let Some(indices) = ensure_int_vector(&tokens[1..]) {
72                    delete_bookmarks_by_indices(
73                        indices,
74                        bookmarks,
75                        services.bookmark_service.clone(),
76                        settings,
77                    )?;
78                } else {
79                    eprintln!("Invalid input, only numbers allowed");
80                    continue;
81                }
82                break;
83            }
84            "e" => {
85                if tokens.len() == 1 {
86                    // Just "e" command with no indices - edit all bookmarks
87                    edit_all_bookmarks(
88                        bookmarks,
89                        services.bookmark_service.clone(),
90                        services.template_service.clone(),
91                        settings,
92                    )?;
93                } else if let Some(indices) = ensure_int_vector(&tokens[1..]) {
94                    edit_bookmarks_by_indices(
95                        indices,
96                        bookmarks,
97                        services.bookmark_service.clone(),
98                        services.template_service.clone(),
99                        settings,
100                    )?;
101                } else {
102                    eprintln!("Invalid input, only numbers allowed");
103                    continue;
104                }
105                break;
106            }
107            "t" => {
108                if let Some(indices) = ensure_int_vector(&tokens[1..]) {
109                    touch_bookmarks_by_indices(
110                        indices,
111                        bookmarks,
112                        services.bookmark_service.clone(),
113                        settings,
114                    )?;
115                } else {
116                    eprintln!("Invalid input, only numbers allowed");
117                    continue;
118                }
119                break;
120            }
121            "y" => {
122                if let Some(indices) = ensure_int_vector(&tokens[1..]) {
123                    yank_bookmark_urls_by_indices(
124                        indices,
125                        bookmarks,
126                        services.interpolation_service.clone(),
127                        services.clipboard_service.clone(),
128                    )?;
129                } else {
130                    eprintln!("Invalid input, only numbers allowed");
131                    continue;
132                }
133                break;
134            }
135            "h" => println!("{}", help_text),
136            "q" => break,
137            s if regex.is_match(s) => {
138                if let Some(indices) = ensure_int_vector(&tokens) {
139                    // Instead of just opening, perform the default action
140                    execute_default_actions_by_indices(
141                        indices,
142                        bookmarks,
143                        services.action_service.clone(),
144                    )?;
145                } else {
146                    eprintln!("Invalid input, only numbers allowed");
147                    continue;
148                }
149                break;
150            }
151            _ => {
152                println!("Invalid Input");
153                println!("{}", help_text);
154            }
155        }
156    }
157
158    Ok(())
159}
160
161/// Parse input string into tokens
162#[instrument(level = "trace")]
163fn parse_input(input: &str) -> Vec<String> {
164    input
165        .trim()
166        .replace(',', " ")
167        .to_lowercase()
168        .split_whitespace()
169        .map(|s| s.to_string())
170        .collect()
171}
172
173/// Get bookmark by index in the displayed list
174#[instrument(skip(bookmarks), level = "trace")]
175fn get_bookmark_by_index(index: i32, bookmarks: &[Bookmark]) -> Option<&Bookmark> {
176    if index < 1 || index as usize > bookmarks.len() {
177        return None;
178    }
179    Some(&bookmarks[index as usize - 1])
180}
181
182#[instrument(
183    skip(bookmarks, interpolation_service, clipboard_service),
184    level = "debug"
185)]
186fn yank_bookmark_urls_by_indices(
187    indices: Vec<i32>,
188    bookmarks: &[Bookmark],
189    interpolation_service: Arc<dyn InterpolationService>,
190    clipboard_service: Arc<dyn ClipboardService>,
191) -> CliResult<()> {
192    debug!(
193        "Yanking (copying) URLs for bookmarks at indices: {:?}",
194        indices
195    );
196
197    for index in indices {
198        match get_bookmark_by_index(index, bookmarks) {
199            Some(bookmark) => {
200                // Render the URL with interpolation variables if needed
201                let rendered_url = match interpolation_service.render_bookmark_url(bookmark) {
202                    Ok(url) => url,
203                    Err(e) => {
204                        eprintln!("Error rendering URL for bookmark {}: {}", index, e);
205                        continue;
206                    }
207                };
208
209                // Copy to clipboard
210                match clipboard_service.copy_to_clipboard(&rendered_url) {
211                    Ok(_) => eprintln!("Copied to clipboard: {}", rendered_url),
212                    Err(e) => eprintln!("Error copying to clipboard: {}", e),
213                }
214            }
215            None => eprintln!("Index {} out of range", index),
216        }
217    }
218
219    Ok(())
220}
221
222/// Executes the default action for a bookmark
223#[instrument(skip(action_service), level = "debug")]
224pub fn execute_bookmark_default_action(
225    bookmark: &Bookmark,
226    action_service: Arc<dyn ActionService>,
227) -> CliResult<()> {
228    // Get action description for logging, incorporating custom opener if present
229    let base_description = action_service.get_default_action_description(bookmark);
230    let action_description = format_action_description(base_description, bookmark.opener.as_ref());
231    debug!(
232        "Executing default action: {} for bookmark: {}",
233        action_description, bookmark.title
234    );
235
236    // Execute the default action
237    // Terminal has already been cleared before this function is called
238    action_service.execute_default_action(bookmark)?;
239
240    Ok(())
241}
242
243/// Executes default actions for bookmarks by their indices
244#[instrument(skip(bookmarks, action_service), level = "debug")]
245fn execute_default_actions_by_indices(
246    indices: Vec<i32>,
247    bookmarks: &[Bookmark],
248    action_service: Arc<dyn ActionService>,
249) -> CliResult<()> {
250    debug!(
251        "Executing default actions for bookmarks at indices: {:?}",
252        indices
253    );
254
255    for index in indices {
256        match get_bookmark_by_index(index, bookmarks) {
257            Some(bookmark) => {
258                // Determine the action type, incorporating custom opener if present
259                let base_description = action_service.get_default_action_description(bookmark);
260                let action_type =
261                    format_action_description(base_description, bookmark.opener.as_ref());
262
263                // Show what we're doing
264                eprintln!(
265                    "Executing '{}' for bookmark: {} (ID: {})",
266                    action_type,
267                    bookmark.title,
268                    bookmark.id.unwrap_or(0)
269                );
270
271                // Execute the action
272                execute_bookmark_default_action(bookmark, action_service.clone())?
273            }
274            None => eprintln!("Index {} out of range", index),
275        }
276    }
277
278    Ok(())
279}
280
281// For backward compatibility
282#[instrument(skip(action_service), level = "debug")]
283pub fn open_bookmark(bookmark: &Bookmark, action_service: Arc<dyn ActionService>) -> CliResult<()> {
284    // This now delegates to the default action system
285    execute_bookmark_default_action(bookmark, action_service)
286}
287
288/// Touch (update timestamp) of bookmarks by indices
289#[instrument(skip(bookmarks, bookmark_service, settings), level = "debug")]
290fn touch_bookmarks_by_indices(
291    indices: Vec<i32>,
292    bookmarks: &[Bookmark],
293    bookmark_service: Arc<dyn BookmarkService>,
294    settings: &crate::config::Settings,
295) -> CliResult<()> {
296    debug!("Touching bookmarks at indices: {:?}", indices);
297
298    for index in indices {
299        match get_bookmark_by_index(index, bookmarks) {
300            Some(bookmark) => {
301                if let Some(id) = bookmark.id {
302                    bookmark_service
303                        .record_bookmark_access(id)
304                        .map_err(CliError::Application)?;
305
306                    // Display the updated bookmark
307                    if let Ok(Some(updated)) = bookmark_service.get_bookmark(id) {
308                        show_bookmarks(
309                            &[DisplayBookmark::from_domain(&updated)],
310                            ALL_FIELDS,
311                            settings,
312                        );
313                    }
314                }
315            }
316            None => eprintln!("Index {} out of range", index),
317        }
318    }
319
320    Ok(())
321}
322
323/// Print IDs of bookmarks by indices
324#[instrument(skip(bookmarks), level = "debug")]
325fn print_bookmark_ids(indices: Vec<i32>, bookmarks: &[Bookmark]) -> CliResult<()> {
326    let mut ids = Vec::new();
327
328    for index in indices {
329        if let Some(bookmark) = get_bookmark_by_index(index, bookmarks) {
330            if let Some(id) = bookmark.id {
331                ids.push(id);
332            }
333        } else {
334            eprintln!("Index {} out of range", index);
335        }
336    }
337
338    if ids.is_empty() {
339        eprintln!("No bookmark IDs found for the specified indices");
340        io::stdout().flush().map_err(CliError::Io)?;
341        return Ok(());
342    }
343
344    ids.sort();
345    println!(
346        "{}",
347        ids.iter()
348            .map(|id| id.to_string())
349            .collect::<Vec<_>>()
350            .join(",")
351    );
352    io::stdout().flush().map_err(CliError::Io)?; // todo: check if necessary
353
354    Ok(())
355}
356
357/// Print IDs of all bookmarks
358#[instrument(skip(bookmarks), level = "debug")]
359fn print_all_bookmark_ids(bookmarks: &[Bookmark]) -> CliResult<()> {
360    let mut ids: Vec<_> = bookmarks.iter().filter_map(|b| b.id).collect();
361
362    if ids.is_empty() {
363        eprintln!("No bookmark IDs found");
364        io::stdout().flush().map_err(CliError::Io)?; // todo: check if this is needed
365        return Ok(());
366    }
367
368    // Print the count for verification
369    eprintln!("Found {} bookmark IDs", ids.len());
370
371    // Sort and print the IDs
372    ids.sort();
373    println!(
374        "{}",
375        ids.iter()
376            .map(|id| id.to_string())
377            .collect::<Vec<_>>()
378            .join(",")
379    );
380    io::stdout().flush().map_err(CliError::Io)?; // todo: check if this is needed
381
382    Ok(())
383}
384
385/// Edit all bookmarks in the list
386#[instrument(
387    skip(bookmarks, bookmark_service, template_service, settings),
388    level = "debug"
389)]
390fn edit_all_bookmarks(
391    bookmarks: &[Bookmark],
392    bookmark_service: Arc<dyn BookmarkService>,
393    template_service: Arc<dyn TemplateService>,
394    settings: &crate::config::Settings,
395) -> CliResult<()> {
396    // Get IDs from all bookmarks
397    let mut bookmark_ids = Vec::new();
398    for bookmark in bookmarks {
399        if let Some(id) = bookmark.id {
400            bookmark_ids.push(id);
401        }
402    }
403
404    if bookmark_ids.is_empty() {
405        eprintln!("No bookmarks to edit");
406        return Ok(());
407    }
408
409    // Call the edit function with all IDs
410    edit_bookmarks(
411        bookmark_ids,
412        false,
413        bookmark_service,
414        template_service,
415        settings,
416    )
417}
418
419/// Edit bookmarks by their indices
420#[instrument(
421    skip(bookmarks, bookmark_service, template_service, settings),
422    level = "debug"
423)]
424fn edit_bookmarks_by_indices(
425    indices: Vec<i32>,
426    bookmarks: &[Bookmark],
427    bookmark_service: Arc<dyn BookmarkService>,
428    template_service: Arc<dyn TemplateService>,
429    settings: &crate::config::Settings,
430) -> CliResult<()> {
431    // Get IDs from indices
432    let mut bookmark_ids = Vec::new();
433    for index in indices {
434        if let Some(bookmark) = get_bookmark_by_index(index, bookmarks) {
435            if let Some(id) = bookmark.id {
436                bookmark_ids.push(id);
437            }
438        } else {
439            eprintln!("Index {} out of range", index);
440        }
441    }
442
443    // Call the edit function with actual IDs
444    edit_bookmarks(
445        bookmark_ids,
446        false,
447        bookmark_service,
448        template_service,
449        settings,
450    )
451}
452
453/// Edit bookmarks by IDs
454#[instrument(skip(bookmark_service, template_service, settings), level = "debug")]
455pub fn edit_bookmarks(
456    ids: Vec<i32>,
457    force_db: bool,
458    bookmark_service: Arc<dyn BookmarkService>,
459    template_service: Arc<dyn TemplateService>,
460    settings: &crate::config::Settings,
461) -> CliResult<()> {
462    let mut bookmarks_to_edit = Vec::new();
463    let mut updated_count = 0;
464
465    // Fetch bookmarks
466    for id in &ids {
467        if let Ok(Some(bookmark)) = bookmark_service.get_bookmark(*id) {
468            bookmarks_to_edit.push(bookmark);
469        } else {
470            eprintln!("Bookmark with ID {} not found", id);
471        }
472    }
473
474    if bookmarks_to_edit.is_empty() {
475        eprintln!("No bookmarks found to edit");
476        return Ok(());
477    }
478
479    // Display bookmarks before editing
480    let display_bookmarks: Vec<_> = bookmarks_to_edit
481        .iter()
482        .map(DisplayBookmark::from_domain)
483        .collect();
484
485    show_bookmarks(&display_bookmarks, DEFAULT_FIELDS, settings);
486
487    // Process each bookmark with smart edit strategy
488    for bookmark in &bookmarks_to_edit {
489        eprintln!(
490            "Editing: {} (ID: {})",
491            bookmark.title,
492            bookmark.id.unwrap_or(0)
493        );
494
495        // Smart edit strategy: decide whether to edit source file or database content
496        if !force_db && bookmark.file_path.is_some() {
497            // Edit source file directly for file-imported bookmarks
498            if let Err(e) = edit_source_file_and_sync(bookmark, &bookmark_service) {
499                eprintln!("  Failed to edit source file: {}", e);
500                eprintln!("  Falling back to database content editing...");
501                // Fall back to regular database editing
502                if let Err(e2) =
503                    edit_database_content(bookmark, &template_service, &bookmark_service)
504                {
505                    eprintln!("  Failed to edit database content: {}", e2);
506                } else {
507                    updated_count += 1;
508                }
509            } else {
510                updated_count += 1;
511            }
512        } else {
513            // Edit database content for regular bookmarks or when forced
514            if let Err(e) = edit_database_content(bookmark, &template_service, &bookmark_service) {
515                eprintln!("  Failed to edit bookmark: {}", e);
516            } else {
517                updated_count += 1;
518            }
519        }
520    }
521
522    eprintln!("Updated {} bookmarks", updated_count);
523    Ok(())
524}
525
526/// Delete bookmarks by their indices in the displayed list
527#[instrument(skip(bookmarks, bookmark_service, settings), level = "debug")]
528fn delete_bookmarks_by_indices(
529    indices: Vec<i32>,
530    bookmarks: &[Bookmark],
531    bookmark_service: Arc<dyn BookmarkService>,
532    settings: &crate::config::Settings,
533) -> CliResult<()> {
534    // Get IDs from indices
535    let mut bookmark_ids = Vec::new();
536    for index in indices {
537        if let Some(bookmark) = get_bookmark_by_index(index, bookmarks) {
538            if let Some(id) = bookmark.id {
539                bookmark_ids.push(id);
540            }
541        } else {
542            eprintln!("Index {} out of range", index);
543        }
544    }
545
546    // Call the delete function with actual IDs
547    delete_bookmarks(bookmark_ids, bookmark_service, settings)
548}
549
550/// Delete bookmarks by their IDs
551#[instrument(skip(bookmark_service, settings), level = "debug")]
552pub fn delete_bookmarks(
553    ids: Vec<i32>,
554    bookmark_service: Arc<dyn BookmarkService>,
555    settings: &crate::config::Settings,
556) -> CliResult<()> {
557    // Display bookmarks to be deleted
558    let mut bookmarks_to_display = Vec::new();
559    for id in &ids {
560        if let Ok(Some(bookmark)) = bookmark_service.get_bookmark(*id) {
561            bookmarks_to_display.push(DisplayBookmark::from_domain(&bookmark));
562        }
563    }
564
565    if bookmarks_to_display.is_empty() {
566        eprintln!("No bookmarks found to delete");
567        return Ok(());
568    }
569
570    show_bookmarks(&bookmarks_to_display, DEFAULT_FIELDS, settings);
571
572    // Confirm deletion
573    if !confirm("Delete these bookmarks?") {
574        return Err(CliError::OperationAborted);
575    }
576
577    // Sort IDs in reverse to handle database compaction correctly
578    let mut sorted_ids = ids.clone();
579    sorted_ids.sort_by(|a, b| b.cmp(a)); // Reverse sort
580
581    // Delete bookmarks
582    let mut deleted_count = 0;
583    for id in sorted_ids {
584        match bookmark_service.delete_bookmark(id) {
585            Ok(true) => deleted_count += 1,
586            Ok(false) => eprintln!("Bookmark with ID {} not found", id),
587            Err(e) => eprintln!("Error deleting bookmark with ID {}: {}", id, e),
588        }
589    }
590
591    eprintln!("Deleted {} bookmarks", deleted_count);
592    Ok(())
593}
594
595#[instrument(skip(clipboard_service), level = "debug")]
596pub fn copy_url_to_clipboard(
597    url: &str,
598    clipboard_service: Arc<dyn ClipboardService>,
599) -> CliResult<()> {
600    match clipboard_service.copy_to_clipboard(url) {
601        Ok(_) => {
602            eprintln!("Copied to clipboard: {}", url);
603            Ok(())
604        }
605        Err(e) => Err(CliError::CommandFailed(format!(
606            "Failed to copy URL to clipboard: {}",
607            e
608        ))),
609    }
610}
611
612#[instrument(skip(interpolation_service, clipboard_service), level = "debug")]
613pub fn copy_bookmark_url_to_clipboard(
614    bookmark: &Bookmark,
615    interpolation_service: Arc<dyn InterpolationService>,
616    clipboard_service: Arc<dyn ClipboardService>,
617) -> CliResult<()> {
618    // Render the URL (apply interpolation)
619    let rendered_url = interpolation_service
620        .render_bookmark_url(bookmark)
621        .map_err(|e| CliError::CommandFailed(format!("Failed to render URL: {}", e)))?;
622
623    // Copy the rendered URL to clipboard
624    copy_url_to_clipboard(&rendered_url, clipboard_service)
625}
626
627/// Clone a bookmark by ID, opening the editor to modify it before saving
628#[instrument(skip(bookmark_service, template_service), level = "debug")]
629pub fn clone_bookmark(
630    id: i32,
631    bookmark_service: Arc<dyn BookmarkService>,
632    template_service: Arc<dyn TemplateService>,
633) -> CliResult<()> {
634    // Get the bookmark to clone
635    let bookmark = bookmark_service
636        .get_bookmark(id)?
637        .ok_or_else(|| CliError::InvalidInput(format!("No bookmark found with ID {}", id)))?;
638
639    println!(
640        "Cloning bookmark: {} (ID: {})",
641        bookmark.title,
642        bookmark.id.unwrap_or(0)
643    );
644
645    // Create a template with the bookmark data but WITHOUT ID
646    let mut temp_bookmark = bookmark.clone();
647    // Clear the ID to ensure a new bookmark will be created
648    temp_bookmark.id = None;
649
650    // Open the editor with the prepared template
651    match template_service.edit_bookmark_with_template(Some(temp_bookmark)) {
652        Ok((edited_bookmark, was_modified)) => {
653            if !was_modified {
654                println!("No changes made in editor. Bookmark not cloned.");
655                return Ok(());
656            }
657
658            // Add the edited bookmark as a new bookmark
659            match bookmark_service.add_bookmark(
660                &edited_bookmark.url,
661                Some(&edited_bookmark.title),
662                Some(&edited_bookmark.description),
663                Some(&edited_bookmark.tags),
664                false, // Don't fetch metadata since we've already edited it
665                true,  // embeddable by default
666            ) {
667                Ok(new_bookmark) => {
668                    println!(
669                        "Added cloned bookmark: {} (ID: {})",
670                        new_bookmark.title,
671                        new_bookmark.id.unwrap_or(0)
672                    );
673                }
674                Err(e) => {
675                    return Err(CliError::CommandFailed(format!(
676                        "Failed to add cloned bookmark: {}",
677                        e
678                    )));
679                }
680            }
681        }
682        Err(e) => {
683            return Err(CliError::CommandFailed(format!(
684                "Failed to edit bookmark: {}",
685                e
686            )));
687        }
688    }
689
690    Ok(())
691}
692
693/// Edit source file directly and sync changes back to database
694fn edit_source_file_and_sync(
695    bookmark: &Bookmark,
696    bookmark_service: &Arc<dyn crate::application::services::bookmark_service::BookmarkService>,
697) -> CliResult<()> {
698    use crate::config::{load_settings, resolve_file_path};
699    use std::path::Path;
700    use std::process::Command;
701
702    // Get the file path
703    let file_path_str = bookmark
704        .file_path
705        .as_ref()
706        .ok_or_else(|| CliError::InvalidInput("No file path for this bookmark".to_string()))?;
707
708    // Load settings to resolve base path variables
709    let settings = load_settings(None)
710        .map_err(|e| CliError::Other(format!("Failed to load settings: {}", e)))?;
711
712    // Resolve the file path (handle base path variables and environment variables)
713    let resolved_path = resolve_file_path(&settings, file_path_str);
714    let source_file = Path::new(&resolved_path);
715
716    // Check if file exists
717    if !source_file.exists() {
718        return Err(CliError::InvalidInput(format!(
719            "Source file does not exist: {}",
720            resolved_path
721        )));
722    }
723
724    eprintln!("  Editing source file: {}", resolved_path);
725
726    // Get the editor command
727    let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
728
729    // Edit the file with the user's preferred editor
730    let status = Command::new(&editor)
731        .arg(&resolved_path)
732        .status()
733        .map_err(|e| {
734            CliError::CommandFailed(format!("Failed to start editor '{}': {}", editor, e))
735        })?;
736
737    if !status.success() {
738        return Err(CliError::CommandFailed(format!(
739            "Editor '{}' exited with non-zero status",
740            editor
741        )));
742    }
743
744    eprintln!("  File edited successfully, syncing changes to database...");
745
746    // Sync changes by updating the specific bookmark with new file content and metadata
747    match sync_file_to_bookmark(bookmark, &resolved_path, bookmark_service) {
748        Ok(()) => {
749            eprintln!("  Successfully synced changes to database");
750        }
751        Err(e) => {
752            return Err(CliError::CommandFailed(format!(
753                "Failed to sync file changes to database: {}",
754                e
755            )));
756        }
757    }
758
759    Ok(())
760}
761
762/// Edit database content using the traditional template-based approach
763fn edit_database_content(
764    bookmark: &Bookmark,
765    template_service: &Arc<dyn crate::application::services::template_service::TemplateService>,
766    bookmark_service: &Arc<dyn crate::application::services::bookmark_service::BookmarkService>,
767) -> CliResult<()> {
768    match template_service.edit_bookmark_with_template(Some(bookmark.clone())) {
769        Ok((updated_bookmark, was_modified)) => {
770            if !was_modified {
771                eprintln!("  No changes made, skipping update");
772                return Ok(());
773            }
774
775            // Check if it's an update or a new bookmark
776            if updated_bookmark.id.is_some() {
777                // Update existing bookmark
778                match bookmark_service.update_bookmark(updated_bookmark, false) {
779                    Ok(_) => {
780                        eprintln!("  Successfully updated bookmark");
781                    }
782                    Err(e) => return Err(CliError::Application(e)),
783                }
784            } else {
785                // Create new bookmark
786                let new_bookmark = updated_bookmark;
787                match bookmark_service.add_bookmark(
788                    &new_bookmark.url,
789                    Some(&new_bookmark.title),
790                    Some(&new_bookmark.description),
791                    Some(&new_bookmark.tags),
792                    false, // Don't fetch metadata since we already have everything
793                    true,  // embeddable by default
794                ) {
795                    Ok(_) => {
796                        eprintln!("  Successfully created new bookmark");
797                    }
798                    Err(e) => return Err(CliError::Application(e)),
799                }
800            }
801        }
802        Err(e) => return Err(CliError::Application(e)),
803    }
804
805    Ok(())
806}
807
808/// Sync file changes to a specific bookmark in the database
809fn sync_file_to_bookmark(
810    original_bookmark: &Bookmark,
811    file_path: &str,
812    bookmark_service: &Arc<dyn crate::application::services::bookmark_service::BookmarkService>,
813) -> CliResult<()> {
814    use crate::infrastructure::repositories::file_import_repository::FileImportRepository;
815    use std::path::Path;
816
817    // Process the file to get updated metadata and content
818    let file_repo = FileImportRepository::new();
819    let file_data = file_repo
820        .process_file(Path::new(file_path))
821        .map_err(|e| CliError::Other(format!("Failed to process file: {}", e)))?;
822
823    // Ensure the bookmark has an ID for updating
824    let _bookmark_id = original_bookmark
825        .id
826        .ok_or_else(|| CliError::InvalidInput("Bookmark has no ID".to_string()))?;
827
828    // Create an updated bookmark based on the original but with new file data
829    let mut updated_bookmark = original_bookmark.clone();
830
831    // Update core content and metadata from file
832    updated_bookmark.title = file_data.name; // frontmatter name becomes title
833    updated_bookmark.url = file_data.content; // file content goes to url field
834
835    // Parse and update tags
836    updated_bookmark.tags = file_data.tags;
837
838    // Update file tracking information
839    updated_bookmark.file_path = Some(file_data.file_path.display().to_string());
840    updated_bookmark.file_mtime = Some(file_data.file_mtime as i32);
841    updated_bookmark.file_hash = Some(file_data.file_hash);
842
843    // Update the content type if specified in frontmatter
844    if !file_data.content_type.is_empty() {
845        use crate::domain::system_tag::SystemTag;
846
847        // Remove old content type system tags
848        let system_tags_to_remove: Vec<_> = updated_bookmark
849            .tags
850            .iter()
851            .filter(|tag| tag.is_known_system_tag())
852            .cloned()
853            .collect();
854
855        for tag in system_tags_to_remove {
856            updated_bookmark.tags.remove(&tag);
857        }
858
859        // Add new content type tag
860        let system_tag = match file_data.content_type.as_str() {
861            "_snip_" => Some(SystemTag::Snippet),
862            "_shell_" => Some(SystemTag::Shell),
863            "_md_" => Some(SystemTag::Markdown),
864            "_env_" => Some(SystemTag::Env),
865            "_imported_" => Some(SystemTag::Text),
866            "_mem_" => Some(SystemTag::Memory),
867            _ => None,
868        };
869
870        if let Some(sys_tag) = system_tag {
871            if let Ok(tag) = sys_tag.to_tag() {
872                updated_bookmark.tags.insert(tag);
873            }
874        }
875    }
876
877    // Update the bookmark in the database
878    bookmark_service
879        .update_bookmark(updated_bookmark, false)
880        .map_err(CliError::Application)?;
881
882    Ok(())
883}
884
885#[cfg(test)]
886mod tests {
887    use super::*;
888    use crate::domain::tag::Tag;
889    use crate::util::testing::init_test_env;
890    use std::collections::HashSet;
891
892    #[test]
893    fn given_input_string_when_parse_input_then_returns_sorted_tokens() {
894        let input = "  1 2,  3  ";
895        let tokens = parse_input(input);
896        assert_eq!(tokens, vec!["1", "2", "3"]);
897
898        let input = "p 1,2,3";
899        let tokens = parse_input(input);
900        assert_eq!(tokens, vec!["p", "1", "2", "3"]);
901    }
902
903    #[test]
904    fn given_bookmarks_and_valid_index_when_get_bookmark_by_index_then_returns_bookmark() {
905        // Create test bookmarks
906        let mut tags = HashSet::new();
907        tags.insert(Tag::new("test").unwrap());
908
909        let bookmark1 = Bookmark {
910            id: Some(10),
911            url: "https://example.com".to_string(),
912            title: "Example".to_string(),
913            description: "An example site".to_string(),
914            tags: tags.clone(),
915            access_count: 0,
916            created_at: Some(chrono::Utc::now()),
917            updated_at: chrono::Utc::now(),
918            embedding: None,
919            content_hash: None,
920            embeddable: false,
921            file_path: None,
922            file_mtime: None,
923            file_hash: None,
924            opener: None,
925            accessed_at: None,
926        };
927
928        let bookmark2 = Bookmark {
929            id: Some(20),
930            url: "https://test.com".to_string(),
931            title: "Test".to_string(),
932            description: "A test site".to_string(),
933            tags,
934            access_count: 0,
935            created_at: Some(chrono::Utc::now()),
936            updated_at: chrono::Utc::now(),
937            embedding: None,
938            content_hash: None,
939            embeddable: false,
940            file_path: None,
941            file_mtime: None,
942            file_hash: None,
943            opener: None,
944            accessed_at: None,
945        };
946
947        let bookmarks = vec![bookmark1, bookmark2];
948
949        // Valid index
950        let bookmark = get_bookmark_by_index(1, &bookmarks);
951        assert!(bookmark.is_some());
952        assert_eq!(bookmark.unwrap().id, Some(10));
953
954        // Out of range
955        let bookmark = get_bookmark_by_index(3, &bookmarks);
956        assert!(bookmark.is_none());
957
958        // Negative index (invalid)
959        let bookmark = get_bookmark_by_index(-1, &bookmarks);
960        assert!(bookmark.is_none());
961    }
962
963    #[test]
964    fn given_bookmarks_and_indices_when_yank_urls_then_copies_urls_to_clipboard() {
965        // Arrange
966        let _ = init_test_env();
967
968        // Create test bookmarks
969        let mut tags = HashSet::new();
970        tags.insert(Tag::new("test").unwrap());
971
972        let bookmark1 = Bookmark {
973            id: Some(10),
974            url: "https://example.com".to_string(),
975            title: "Example".to_string(),
976            description: "An example site".to_string(),
977            tags: tags.clone(),
978            access_count: 0,
979            created_at: Some(chrono::Utc::now()),
980            updated_at: chrono::Utc::now(),
981            embedding: None,
982            content_hash: None,
983            embeddable: false,
984            file_path: None,
985            file_mtime: None,
986            file_hash: None,
987            opener: None,
988            accessed_at: None,
989        };
990
991        let bookmark2 = Bookmark {
992            id: Some(20),
993            url: "https://test.com".to_string(),
994            title: "Test".to_string(),
995            description: "A test site".to_string(),
996            tags,
997            access_count: 0,
998            created_at: Some(chrono::Utc::now()),
999            updated_at: chrono::Utc::now(),
1000            embedding: None,
1001            content_hash: None,
1002            embeddable: false,
1003            file_path: None,
1004            file_mtime: None,
1005            file_hash: None,
1006            opener: None,
1007            accessed_at: None,
1008        };
1009
1010        let bookmarks = vec![bookmark1, bookmark2];
1011
1012        // Act - test that the function executes without errors
1013        // We can't easily test clipboard content in unit tests
1014        // Create a temporary test service container for testing
1015        use crate::util::test_service_container::TestServiceContainer;
1016        let services = TestServiceContainer::new();
1017
1018        let result = yank_bookmark_urls_by_indices(
1019            vec![1],
1020            &bookmarks,
1021            services.interpolation_service.clone(),
1022            services.clipboard_service.clone(),
1023        );
1024
1025        // Assert
1026        assert!(result.is_ok(), "Yank operation should succeed");
1027    }
1028}